Skip to content

Add native Windows support (ConPTY + pyte backend, platform abstraction) - #1107

Open
zhxf94 wants to merge 18 commits into
gnome-terminator:masterfrom
zhxf94:feature/windows-port
Open

Add native Windows support (ConPTY + pyte backend, platform abstraction)#1107
zhxf94 wants to merge 18 commits into
gnome-terminator:masterfrom
zhxf94:feature/windows-port

Conversation

@zhxf94

@zhxf94 zhxf94 commented Jul 29, 2026

Copy link
Copy Markdown

Summary

Makes Terminator run natively on Windows (Windows 10 1809+, which provides
the ConPTY API). The historical blocker was libvte, which does PTY
management + VT emulation + glyph rendering in one C library with no Windows
port. This PR keeps Linux behaviour byte-for-byte identical while adding a
pluggable backend that provides a Windows equivalent.

Approach

A concrete terminal backend is-a GTK widget. On Linux VteBackend
subclasses Vte.Terminal, so every existing self.vte.* call site in
terminal.py is unchanged (zero regression). On Windows ConPtyTerminal
subclasses Gtk.DrawingArea and implements the same surface, backed by:

  • ConPTY (CreatePseudoConsole + CreateProcess) for the PTY/process side (ctypes, no SDK);
  • pyte for VT/xterm emulation (pure Python, unit-tested);
  • Pango/Cairo self-rendering of the cell grid (full per-run colour, cursor shapes/colours/blink, East-Asian double-width alignment).

Platform-specific concerns (shell lookup, URL opening, config dirs, X11
WINDOWID, D-Bus IPC) are funnelled through a new terminatorlib/platform.py
(pure stdlib). Windows single-instance/forwarding uses a named-pipe IPC
(ipc_win32.py) instead of D-Bus.

Milestones

Area Status
M0 platform abstraction, IPC decoupling, conditional deps
M1 TerminalBackend abstraction + VteBackend (zero-risk)
M2 ConPTY backend + pyte emulation + GTK rendering
M3 GUI/IPC/dependency landing, DWM dark titlebar
M4 PyInstaller spec + Windows CI
M5 contract + platform tests, acceptance matrix
+ render hardening: per-run colour, cursor shapes/blink, wcwidth

Testing

  • 125 tests pass on Linux (24 original + 101 new) — zero Linux regression.
  • New: backend contract test (the full terminal.py method surface, 45 cases),
    platform tests incl. Windows branches via monkeypatch, pyte emulation layer,
    and the renderer drawn against a real Cairo image surface.
  • Windows CI workflow (windows.yml) runs the GTK-free subset on
    windows-latest.

Honest note

ConPTY, the GTK renderer, the named-pipe IPC, and DWM are written and
syntax/logic-verified, but their runtime was not exercised in this PR's
development environment (Linux). The acceptance matrix in
docs/WINDOWS-ACCEPTANCE.md is the manual verification checklist.

Known gaps (non-blocking for alpha)

  • SIXEL graphics (pyte does not support it)
  • OSC-8 hyperlink hover metadata (regex URL matching still works)
    • CJK IME composition

Files

  • terminatorlib/platform.py, terminal_backend.py, ipc_win32.py
  • terminatorlib/backends/{vte_backend,conpty_backend}.py
  • terminatorlib/backends/conpty/{pty,screen}.py
  • modified: terminal.py, util.py, window.py, setup.py, terminator
  • tests, packaging/terminator.spec, .github/workflows/windows.yml, docs

🤖 Generated with Claude Code

WendyZhang1228 and others added 8 commits July 29, 2026 16:24
- Add terminatorlib/platform.py (pure stdlib): IS_WINDOWS, display_manager,
  shell_lookup (pwd on Linux, PowerShell/cmd on Windows), path_lookup
  (PATHEXT aware), open_url (os.startfile on Windows), get_window_id_env,
  XDG vs %APPDATA% config dirs, DWM dark-mode bridge, supports_dbus.
- Refactor util.py to delegate to platform; drop top-level 'import pwd'
  (the hard blocker for importing on Windows).
- terminal.py: replace xdg-open fallback with platform.open_url.
- setup.py: conditional dbus-python (Linux only); add pywin32/pyte
  (Windows only) via PEP 508 markers.
- Add terminatorlib/ipc_win32.py: named-pipe single-instance + command
  forwarding transport, mirroring the DBus service surface (wired in M3).

Linux behaviour unchanged; 24 existing tests + doctests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
- Add terminal_backend.py: TerminalBackend contract + make_terminal_widget()
  factory that selects the concrete backend per platform (lazy import so
  neither VTE-on-Windows nor pywin32-on-Linux is ever loaded on the wrong OS).
- Add backends/ package + backends/vte_backend.py: VteBackend subclasses
  Vte.Terminal (so all ~100 self.vte.* call sites in terminal.py keep working
  unchanged) and centralises the PTY spawn behind a portable spawn() surface
  (the part that is most platform-specific and that ConPTY will override).
- terminal.py: construct via make_terminal_widget(); route spawn_sync /
  spawn_async through self.vte.spawn(args, envv, cwd, flatpak=...).

Linux behaviour identical; VteBackend IS-A Vte.Terminal; 24 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
- backends/conpty/screen.py: pure-Python terminal screen over pyte
  (ByteStream for bytes, Cell model, truecolour/palette decoding, cursor,
  scrollback, URL matching via re ported from regex.py). Tested on Linux.
- backends/conpty/pty.py: Windows ConPTY wrapper via ctypes
  (CreatePseudoConsole/Resize/Close, CreatePipe, STARTUPINFOEX +
  PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, non-blocking PeekNamedPipe read,
  spawn/resize/poll_exit/close). Windows-only; ImportError elsewhere.
- backends/conpty_backend.py: ConPtyTerminal(Gtk.DrawingArea) implementing
  the VteBackend surface for Windows -- spawn/feed/feed_child, Pango/Cairo
  cell rendering with cursor/selection, geometry getters, vadjustment,
  colours/font, URL hit-testing, clipboard, VTE-compatible GObject signals
  (child-exited/bell/selection-changed/window-title-changed) and the
  input-enabled property. Advanced VTE features (SIXEL, OSC-8 hover, cursor
  shape variants) stubbed with M5 TODOs.
- terminal.py: platform-gate the Vte import (Vte=None on Windows); branch
  URL-regex registration to the backend's re-based match_add_regex on
  Windows; guard cursor-shape/blink config; route button-press through a
  new _vte_button_press helper (no-ops on Windows).
- tests/test_conpty_screen.py: 11 unit tests for the emulation layer
  (feed->grid, attributes, truecolour, cursor, resize, URL matching,
  clipboard text).

Co-Authored-By: Claude <noreply@anthropic.com>
- Entry script (terminator): platform-dispatched single-instance IPC. Linux
  keeps the DBus master/forward path unchanged; Windows uses ipc_win32
  (PipeService master + send_command forward) for single-instance and
  new-window/new-tab/reload/toggle/unhide forwarding.
- window.py: Dark/Light/Auto window decoration now applies on Windows via
  DWM (platform.set_window_dark_mode) through a new set_decoration_style()
  dispatcher; X11 keeps the _GTK_THEME_VARIANT path, Wayland/other no-op.
  Keybinder/libX11/blur already guarded by display_manager()=='X11', so
  they short-circuit on Windows.
- Plugin gating needs no code change: PluginRegistry.load_plugins already
  wraps __import__ in try/except, so Vte-dependent plugins
  (command_notify, mousefree_url_handler, remote) skip gracefully on Windows.

Linux behaviour unchanged; 38 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
- packaging/terminator.spec: one-folder PyInstaller build bundling
  terminatorlib + the ConPTY backend + pyte + win32 pipe hidden imports +
  Glade/theme/icon data; optionally collects GTK DLLs via GTK_BIN.
- .github/workflows/windows.yml: windows-latest CI that installs the
  pip-available deps (pyte, pywin32, psutil, configobj) and runs the
  platform-portable tests (borg, signalman, conpty screen) plus
  byte-compilation.
- PACKAGING-WINDOWS.md: build/run instructions and feature coverage notes.

Co-Authored-By: Claude <noreply@anthropic.com>
- tests/test_terminal_backend.py: 45-case contract test pinning the full
  method surface terminal.py relies on (lifecycle, geometry, appearance,
  behaviour, title/cwd, selection/clipboard/URL, GtkWidget) against whatever
  make_terminal_widget() returns on the current platform.
- tests/test_platform.py: 10 tests for platform.py including Windows branches
  exercised via monkeypatch on Linux (config dir, display manager, supports_dbus,
  open_url/startfile, dark-mode no-op).
- Fix platform.open_url to catch OSError (WindowsError subclass) so the
  except clause is valid on non-Windows too.
- docs/WINDOWS-ACCEPTANCE.md: manual verification matrix (cmd/powershell/wsl
  x launch/resize/scrollback/split/tab/broadcast/copy/paste/url/search/
  single-instance/dark-titlebar) plus tracked gaps.

Full suite: 105 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
conpty_backend.py:
- _on_draw rewritten to build one Pango layout per line with a PangoAttrList
  encoding runs of equal (fg, bg, bold, italic, underline, strike), so
  true-colour, palette, reverse-video, bold/italic/underline/strikethrough
  all render correctly. Selection inverts fg<->bg per cell.
- Cursor shapes: block (fill + glyph redrawn in cursor_fg), underline,
  beam/ibeam, positioned at the pyte cursor and drawn last on top.
- Cursor colours: set_color_cursor / set_color_cursor_foreground now store
  (r,g,b) from Gdk.RGBA and are applied in the cursor draw.
- Cursor blink: set_cursor_blink_mode starts/stops a GLib timeout that
  toggles visibility only while the widget is the focus (solid otherwise).
- _resolve_colour generalised; helpers _norm/_pango16/_rgba_to_rgb/
  _emit_run/_cursor_shape_from_value/_cursor_blink_on factored out.

terminal.py: pass cursor_shape (raw string) and cursor_blink (bool) to the
backend on Windows so the config actually takes effect.

tests/test_conpty_render.py: 12 tests -- pure helper coverage + full draw
path against a Cairo image surface (plain/bold/colour/reverse, cursor
shapes, cursor colour, selection, clear-background). Full suite: 117 passed.

Docs updated to move per-cell colour + cursor shape out of the known-gaps
list (SIXEL/OSC-8/IME/wcwidth remain).

Co-Authored-By: Claude <noreply@anthropic.com>
screen.py:
- Cell gains a 'width' slot (1 normal, 2 East-Asian wide, 0 continuation).
- cells() computes width via unicodedata.east_asian_width; pyte's empty
  continuation cell (the second column of a wide glyph) is marked width 0.
- Fix Screen.resize: pyte's resize() takes (lines, columns) -- the opposite
  order of __init__(columns, lines) -- so pass keywords; the previous
  positional call scrambled the grid (swapped rows/cols).

conpty_backend.py:
- _on_draw builds line text from width>0 cells only, advancing the column
  cursor by each cell's width, and uses text indices for Pango attribute
  runs. Pango lays wide glyphs across two columns naturally, so trailing
  cells stay aligned without per-cell positioning.
- _span_in_selection checks a wide glyph's whole column span for inversion.
- _draw_cursor covers the cell's full display width (block/underline span
  2cw over a wide glyph).
- _selection_text skips width-0 continuation cells (no stray trailing
  space when copying a wide glyph).

tests: 8 new (display-width helper, wide/continuation cells, resize keeps
buffer, cursor advance-by-two, CJK draw, cursor-on-wide-glyph, selection
text). Full suite: 125 passed.

Docs: move double-width alignment out of the gaps list.

Co-Authored-By: Claude <noreply@anthropic.com>
@zhxf94 zhxf94 closed this Jul 29, 2026
@zhxf94 zhxf94 reopened this Jul 29, 2026
WendyZhang1228 and others added 6 commits July 29, 2026 17:40
End-user-facing guide (zh-CN) for installing and running Terminator on
Windows: prerequisites, MSYS2/GTK3 setup, dependency install, setup.py
install, direct run, PyInstaller packaging, default-shell configuration
(powershell/cmd/wsl), keybindings, colours/cursor, config file locations,
known alpha limits, troubleshooting.

Co-Authored-By: Claude <noreply@anthropic.com>
- install-windows.bat (double-click) -> install-windows.ps1: winget-installs
  Python + MSYS2, pacman GTK3+PyGObject, pip deps, setup.py install (with
  pip fallback for Py3.12+ distutils removal), writes run-windows.bat with
  baked paths, creates a desktop shortcut.
- run-windows.bat: launch Terminator (MSYS2 MinGW64 python3 + GTK DLLs on
  PATH), runs from source so it works even if setup.py install failed.
- build-windows-package.ps1: after install, runs PyInstaller bundling the
  GTK DLLs (GTK_BIN) into a self-contained dist\terminator\.
- docs/WINDOWS-安装使用指南.md: new section 0 'one-click install' pointing
  at the scripts, with honest notes (winget first-time prompt, pywin32 IPC
  degradation under MSYS2).

Note: PowerShell runtime not syntax-checked (no PS on the dev box); keep
scripts simple + commented for Windows-side adjustment.

Co-Authored-By: Claude <noreply@anthropic.com>
The one-click install failed at the pacman step with 'target not found':
two causes, both fixed:

1. A fresh winget MSYS2 has never synced its package database, so every
   package is 'not found' until pacman -Sy runs. Now runs
   'pacman -Sy --overwrite *' first.

2. MSYS2 renamed python3-* to python-*; python3-gobject/python3-pip/
   python3-psutil no longer resolve. Install-Pkg tries both name variants
   and takes whichever resolves.

Also: detect whether the mingw python is python3.exe or python.exe and bake
that into run-windows.bat; add gobject-introspection (typelibs gi loads);
keep psutil/pycairo on pacman (PyPI binary wheels don't fit mingw python
ABI), pip only for pure-Python pyte/configobj; clearer error if
python-gobject fails (the GUI cannot start without it).

Co-Authored-By: Claude <noreply@anthropic.com>
make-offline-bundle.ps1: snapshot a working MSYS2 + Terminator install into
a portable bundle/ that other machines install with zero network.
- copy mode: robocopy C:\msys64\mingw64 (DLLs, mingw python, PyGObject,
  typelibs, pip pure-python deps) + terminator source into bundle/;
  generates run.bat (sets PATH + GI_TYPELIB_PATH, runs bundled python) and
  install-offline.bat (copies to %LOCALAPPDATA%\Terminator, desktop + start
  menu shortcuts).
- pyinstaller mode: runs build-windows-package.ps1 and uses dist\terminator
  as the bundle (smaller, cleaner) -- recommended for distribution.

The bundle is the 'deps-included' install: double-click install-offline.bat
on any machine, seconds, no internet. (Bundle must be built once on a
machine where the online install already succeeded; cannot be produced from
the Linux dev box.)

Guide updated with an offline-install section.

Co-Authored-By: Claude <noreply@anthropic.com>
The pacman sync step appeared to hang (no output for a long time). Two fixes:

1. Explicitly run 'pacman-key --init' and 'pacman-key --populate msys2' before
   -Sy. A fresh MSYS2 initialises its keyring on first pacman use, which can
   block on entropy with no output -- doing it explicitly makes it observable
   and (usually) fast.

2. New Invoke-BashStream() runs pacman via Start-Process -NoNewWindow so its
   stdout streams live to the console (bash -lc output was buffered), and
   prints a dot every 3s with an elapsed-time marker every 60s while it runs
   -- so the user can always tell it is progressing, not hung.

Install-Pkg now streams too (dropped the 2>$null that hid errors).

Co-Authored-By: Claude <noreply@anthropic.com>
A fresh/stale MSYS2 keyring can't verify packages signed by Christoph
Reiter's rotated key -> 'invalid signature' on every pacman op. Added a
repair pass before the normal sync: make a copy of pacman.conf with
SigLevel=Never, use it to sync the DB and install the latest msys2-keyring
package (unsigned), then 'pacman-key --init' + '--populate msys2' to rebuild
trust from the fresh keyring. The subsequent normal -Sy + installs then
verify. Each sub-step streams output + heartbeat.

Co-Authored-By: Claude <noreply@anthropic.com>
@mattrose

Copy link
Copy Markdown
Member

This is pretty intriguing code. I like how it splits out the backend entirely and doesn't touch the existing terminator code.

I did notice a test failing, can you fix this?

WendyZhang1228 and others added 4 commits July 30, 2026 14:14
…ied'

Invoke-BashStream used `Start-Process -ArgumentList @('-lc', $cmd)`, which
joins the array with spaces into one unquoted string. bash's -c then grabs
only the first token of $cmd as the command and drops the rest as positional
params, so pacman ran with no operation flag -> "错误:没有指定操作".
This silently broke the keyring-repair steps too and only surfaced at sync.

Write $cmd to a temp .sh file and run `bash -l <file>` instead, sidestepping
all quoting (single quotes, glob '*', sed expressions). Use .NET Process
rather than Start-Process so a space in the Windows temp path doesn't split
the path argument.

Co-Authored-By: Claude <noreply@anthropic.com>
On Linux, `use_system_font` resolves the GNOME monospace font (Ubuntu Mono /
DejaVu Sans Mono) so Terminator blends in. On Windows there is no gsettings
schema to query, so it fell back to Pango's generic "monospace" alias at size
10 -- an arbitrary family that looks nothing like the Ubuntu sibling. The cell
colours are already identical everywhere (Tango palette, black bg), so the font
is the only thing that needs pinning.

DejaVu isn't an MSYS2 package and isn't on a stock Windows box, so pinning the
family alone would just get silently substituted by fontconfig. Instead:
  - bundle DejaVuSansMono{,-Bold}.ttf (+ LICENSE) under data/fonts/
  - on Windows default profile: use_system_font=False, font="DejaVu Sans Mono 12"
  - install-windows.ps1 copies the TTFs into the per-user Windows font dir
    (fontconfig scans WINDOWSUSERFONTDIR -- no admin) and runs fc-cache -f
  - make-offline-bundle.ps1's install-offline.bat does the same for offline
    installs
  - MANIFEST.in ships the fonts in sdists

No behaviour change on Linux (os.name != 'nt').

Co-Authored-By: Claude <noreply@anthropic.com>
… crash)

Launch crashed immediately with "Namespace Vte not available" because
terminatorlib/terminator.py (the master singleton) did
`gi.require_version('Vte', '2.91')` unconditionally at import time. Vte is
unavailable on Windows and isn't needed there (the ConPTY backend replaces
it), but the import runs before anything else. terminal.py already gated
this import behind `platform.IS_WINDOWS`; three sibling modules in the
startup chain were missed:

  - terminatorlib/terminator.py  -- imported first by the entry script;
                                    the actual crash site. Vte is only used
                                    for the use_theme_colors probe (a dummy
                                    Vte.Terminal to read theme colours); skip
                                    it on Windows, falling back to the
                                    explicit profile background_colour.
  - terminatorlib/searchbar.py   -- imported at top of terminal.py. Vte is
                                    only used for PCRE2 search; on Windows
                                    do_search() now short-circuits (the
                                    ConPTY backend has no search API) instead
                                    of crashing on search_set_regex.
  - terminatorlib/regex.py       -- imported at top of terminal.py. With
                                    Vte None, FLAGS_PCRE2 becomes None and
                                    searchbar falls back to GLib.Regex.

The same `if not platform.IS_WINDOWS: ... else: Vte = None` pattern already
used in terminal.py. No behaviour change on Linux (verified: 119 tests pass;
Windows path simulated by patching platform.IS_WINDOWS=True -- all three
import cleanly with Vte=None).

The crash also blocked the split-terminal feature and the Ubuntu-style font
from the previous commit, since the app never reached the window.

Co-Authored-By: Claude <noreply@anthropic.com>
Launch crashed at `from configobj import` because configobj was never
installed: the pip step `pip install --upgrade pip pyte configobj` failed
silently. Two root causes:

  1. MSYS2 mingw python ships the PEP-668 EXTERNALLY-MANAGED marker, so a
     plain `pip install` is rejected with "externally-managed-environment".
  2. `--upgrade pip` fights pacman's managed pip.

configobj is BSD-licensed and pure-Python, and is not a mingw MSYS2 package
(only an msys one, invisible to mingw python), so vendor it under
terminatorlib/_vendor (configobj + validate shim + LICENSE). terminatorlib
__init__ appends _vendor to sys.path (last, so a system install wins when
present; the vendored copy is the fallback). config.py is unchanged -- its
`from configobj import` / `from validate import` resolve to the vendored
copy when nothing else is installed. Verified: simulated absence of system
configobj (python -S) -> vendored resolves, config.py imports past the
configobj lines.

pyte is LGPLv3 (incompatible with Terminator's GPLv2-only), so it cannot be
vendored -- it must come from pip. Fix the pip command: drop --upgrade pip,
add --break-system-packages (with a no-flag retry for older pip), and
verify `import pyte` succeeds, Die-ing loudly if not (the ConPTY backend
cannot run without it). Also pass --break-system-packages to the setup.py
fallback `pip install .`.

Defense in depth: cwd.py imported psutil at module top, and
get_pid_cwd() runs in Terminal.__init__ -- so a missing psutil would crash
the next terminal creation. Guard the import and fall back to os.getcwd()
when psutil is absent (the terminal still launches; only "open split in
parent dir" degrades).

Also: setup.py now ships the _vendor subpackages and drops configobj from
install_requires (vendored). MANIFEST.in includes the vendored files.

No behaviour change on Linux (119 tests pass; system configobj still wins).

Co-Authored-By: Claude <noreply@anthropic.com>
@mattrose

mattrose commented Aug 9, 2026

Copy link
Copy Markdown
Member

Reading over the code a little more, and I have a couple of things, and aside from the comments in the review, I have a few more comments.

  1. remove the vendored configobj. vendored code doesn't belong in this repo. pyinstaller should be able to install this from pypi or some other source.
  2. the fonts should not be added to the repo. We should either use fonts installed by default or installed from their proper location
  3. tests are still failing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants