pyqterminal 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyqterminal-0.1.0/LICENSE +21 -0
- pyqterminal-0.1.0/PKG-INFO +120 -0
- pyqterminal-0.1.0/README.md +96 -0
- pyqterminal-0.1.0/pyproject.toml +29 -0
- pyqterminal-0.1.0/pyqterminal.egg-info/PKG-INFO +120 -0
- pyqterminal-0.1.0/pyqterminal.egg-info/SOURCES.txt +11 -0
- pyqterminal-0.1.0/pyqterminal.egg-info/dependency_links.txt +1 -0
- pyqterminal-0.1.0/pyqterminal.egg-info/requires.txt +2 -0
- pyqterminal-0.1.0/pyqterminal.egg-info/top_level.txt +1 -0
- pyqterminal-0.1.0/setup.cfg +4 -0
- pyqterminal-0.1.0/terminal/__init__.py +0 -0
- pyqterminal-0.1.0/terminal/input_handler.py +109 -0
- pyqterminal-0.1.0/terminal/widget.py +672 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaihong Han
|
|
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.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyqterminal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A cross-platform terminal emulator with Rust backend and PySide6 frontend
|
|
5
|
+
Author: Kaihong Han
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/hanyoukuang/pyqterminal
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Rust
|
|
17
|
+
Classifier: Topic :: Terminals
|
|
18
|
+
Requires-Python: >=3.12.13
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: par-term-emu-core-rust>=0.42
|
|
22
|
+
Requires-Dist: pyside6>=6.11.1
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# pyqterminal
|
|
26
|
+
|
|
27
|
+
A cross-platform terminal emulator — Python frontend, Rust backend.
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<img src="screenshot.png" alt="pyqterminal Screenshot" width="720">
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- 🦀 **Rust-powered TUI** — parses VT520 escape sequences via the `vte` crate
|
|
36
|
+
- 🎨 **Full SGR support** — bold, italic, underline (5 styles: straight, double, curly, dotted, dashed), reverse video, dim, blink, strikethrough, hidden text
|
|
37
|
+
- 🌐 **CJK** — proper double-width rendering for Chinese, Japanese, Korean
|
|
38
|
+
- 🔣 **Nerd Font** — renders icon glyphs (Powerlevel10k, oh-my-zsh themes)
|
|
39
|
+
- 🖱️ **Mouse** — text selection with auto-copy, right-click context menu, scrollback with wheel
|
|
40
|
+
- 📋 **Clipboard** — Cmd+C / Cmd+V (macOS), Ctrl+Shift+C / Ctrl+Shift+V (Linux/Windows)
|
|
41
|
+
- 🔍 **Zoom** — Ctrl++/-/0 adjust font size (6–32pt)
|
|
42
|
+
- ⚡ **No buffering** — renders directly via QPainter, no QPixmap double-buffer (Retina-safe)
|
|
43
|
+
- 🪟 **Cross-platform** — macOS, Linux, Windows
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Requires Python 3.12.13+ and [`uv`](https://docs.astral.sh/uv/).
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/hanyoukuang/pyqterminal.git
|
|
51
|
+
cd pyqterminal
|
|
52
|
+
uv sync
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### Interactive mode (default)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv run python main.py
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Display-only mode
|
|
64
|
+
|
|
65
|
+
Use pyqterminal as a pure terminal display — pipe escape sequences from external sources (SSH, logs, etc.) without a local shell:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Pipe ANSI output to pyqterminal
|
|
69
|
+
echo -e '\x1b[31mHello\x1b[0m\n\x1b[7mReverse\x1b[0m' | uv run python main.py --display
|
|
70
|
+
|
|
71
|
+
# Display SSH session output
|
|
72
|
+
ssh user@host 2>&1 | uv run python main.py --display
|
|
73
|
+
|
|
74
|
+
# Programmatic usage
|
|
75
|
+
python -c "
|
|
76
|
+
from terminal.widget import TerminalWidget
|
|
77
|
+
widget = TerminalWidget(rows=24, cols=80, display_only=True)
|
|
78
|
+
widget.feed('\x1b[31mRed text\x1b[0m\n')
|
|
79
|
+
widget.feed('\x1b[47m\x1b[30mBlack on white\x1b[0m\n')
|
|
80
|
+
"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Keyboard shortcuts:
|
|
84
|
+
|
|
85
|
+
| Shortcut | Action |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `Cmd+C` / `Ctrl+Shift+C` | Copy selection |
|
|
88
|
+
| `Cmd+V` / `Ctrl+Shift+V` | Paste |
|
|
89
|
+
| `Ctrl++` / `Ctrl+-` / `Ctrl+0` | Zoom in / out / reset |
|
|
90
|
+
| `Shift+PageUp` / `Shift+PageDown` | Scroll back / forward |
|
|
91
|
+
| Mouse drag | Select text (auto-copied on release) |
|
|
92
|
+
| Mouse wheel | Scroll |
|
|
93
|
+
| Middle-click | Paste |
|
|
94
|
+
|
|
95
|
+
## Architecture
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Interactive: main.py → TerminalWidget → PtyTerminal (Rust, PTY)
|
|
99
|
+
Display-only: main.py → TerminalWidget → Terminal (Rust, headless)
|
|
100
|
+
├── InputHandler (QKeyEvent → terminal bytes)
|
|
101
|
+
└── QPainter (direct paintEvent rendering)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- **Backend:** [`par-term-emu-core-rust`](https://github.com/paulrobello/par-term-emu-core-rust) — Rust `vte` crate handles PTY, escape parsing, buffer, colors, cursor, scrollback
|
|
105
|
+
- **Frontend:** PySide6 `QPainter` — renders directly in `paintEvent()`, no QPixmap double-buffer (avoids Retina/HiDPI issues)
|
|
106
|
+
- **Input:** `InputHandler` maps `QKeyEvent` to terminal escape sequences
|
|
107
|
+
|
|
108
|
+
### Rendering pipeline
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Shell output → PTY → Rust vte parser → get_line_cells(row) → _render_cells() → QPainter
|
|
112
|
+
│
|
|
113
|
+
reverse swap · dim · bold/italic
|
|
114
|
+
hidden · blink · wide char (2 cols)
|
|
115
|
+
strikethrough · underline (5 styles)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT © 2026 Kaihong Han
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# pyqterminal
|
|
2
|
+
|
|
3
|
+
A cross-platform terminal emulator — Python frontend, Rust backend.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="screenshot.png" alt="pyqterminal Screenshot" width="720">
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 🦀 **Rust-powered TUI** — parses VT520 escape sequences via the `vte` crate
|
|
12
|
+
- 🎨 **Full SGR support** — bold, italic, underline (5 styles: straight, double, curly, dotted, dashed), reverse video, dim, blink, strikethrough, hidden text
|
|
13
|
+
- 🌐 **CJK** — proper double-width rendering for Chinese, Japanese, Korean
|
|
14
|
+
- 🔣 **Nerd Font** — renders icon glyphs (Powerlevel10k, oh-my-zsh themes)
|
|
15
|
+
- 🖱️ **Mouse** — text selection with auto-copy, right-click context menu, scrollback with wheel
|
|
16
|
+
- 📋 **Clipboard** — Cmd+C / Cmd+V (macOS), Ctrl+Shift+C / Ctrl+Shift+V (Linux/Windows)
|
|
17
|
+
- 🔍 **Zoom** — Ctrl++/-/0 adjust font size (6–32pt)
|
|
18
|
+
- ⚡ **No buffering** — renders directly via QPainter, no QPixmap double-buffer (Retina-safe)
|
|
19
|
+
- 🪟 **Cross-platform** — macOS, Linux, Windows
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Requires Python 3.12.13+ and [`uv`](https://docs.astral.sh/uv/).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/hanyoukuang/pyqterminal.git
|
|
27
|
+
cd pyqterminal
|
|
28
|
+
uv sync
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### Interactive mode (default)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv run python main.py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Display-only mode
|
|
40
|
+
|
|
41
|
+
Use pyqterminal as a pure terminal display — pipe escape sequences from external sources (SSH, logs, etc.) without a local shell:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Pipe ANSI output to pyqterminal
|
|
45
|
+
echo -e '\x1b[31mHello\x1b[0m\n\x1b[7mReverse\x1b[0m' | uv run python main.py --display
|
|
46
|
+
|
|
47
|
+
# Display SSH session output
|
|
48
|
+
ssh user@host 2>&1 | uv run python main.py --display
|
|
49
|
+
|
|
50
|
+
# Programmatic usage
|
|
51
|
+
python -c "
|
|
52
|
+
from terminal.widget import TerminalWidget
|
|
53
|
+
widget = TerminalWidget(rows=24, cols=80, display_only=True)
|
|
54
|
+
widget.feed('\x1b[31mRed text\x1b[0m\n')
|
|
55
|
+
widget.feed('\x1b[47m\x1b[30mBlack on white\x1b[0m\n')
|
|
56
|
+
"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Keyboard shortcuts:
|
|
60
|
+
|
|
61
|
+
| Shortcut | Action |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `Cmd+C` / `Ctrl+Shift+C` | Copy selection |
|
|
64
|
+
| `Cmd+V` / `Ctrl+Shift+V` | Paste |
|
|
65
|
+
| `Ctrl++` / `Ctrl+-` / `Ctrl+0` | Zoom in / out / reset |
|
|
66
|
+
| `Shift+PageUp` / `Shift+PageDown` | Scroll back / forward |
|
|
67
|
+
| Mouse drag | Select text (auto-copied on release) |
|
|
68
|
+
| Mouse wheel | Scroll |
|
|
69
|
+
| Middle-click | Paste |
|
|
70
|
+
|
|
71
|
+
## Architecture
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Interactive: main.py → TerminalWidget → PtyTerminal (Rust, PTY)
|
|
75
|
+
Display-only: main.py → TerminalWidget → Terminal (Rust, headless)
|
|
76
|
+
├── InputHandler (QKeyEvent → terminal bytes)
|
|
77
|
+
└── QPainter (direct paintEvent rendering)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- **Backend:** [`par-term-emu-core-rust`](https://github.com/paulrobello/par-term-emu-core-rust) — Rust `vte` crate handles PTY, escape parsing, buffer, colors, cursor, scrollback
|
|
81
|
+
- **Frontend:** PySide6 `QPainter` — renders directly in `paintEvent()`, no QPixmap double-buffer (avoids Retina/HiDPI issues)
|
|
82
|
+
- **Input:** `InputHandler` maps `QKeyEvent` to terminal escape sequences
|
|
83
|
+
|
|
84
|
+
### Rendering pipeline
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Shell output → PTY → Rust vte parser → get_line_cells(row) → _render_cells() → QPainter
|
|
88
|
+
│
|
|
89
|
+
reverse swap · dim · bold/italic
|
|
90
|
+
hidden · blink · wide char (2 cols)
|
|
91
|
+
strikethrough · underline (5 styles)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT © 2026 Kaihong Han
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyqterminal"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A cross-platform terminal emulator with Rust backend and PySide6 frontend"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Kaihong Han" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.12.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"par-term-emu-core-rust>=0.42",
|
|
13
|
+
"pyside6>=6.11.1",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: X11 Applications :: Qt",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Rust",
|
|
25
|
+
"Topic :: Terminals",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/hanyoukuang/pyqterminal"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyqterminal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A cross-platform terminal emulator with Rust backend and PySide6 frontend
|
|
5
|
+
Author: Kaihong Han
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/hanyoukuang/pyqterminal
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Rust
|
|
17
|
+
Classifier: Topic :: Terminals
|
|
18
|
+
Requires-Python: >=3.12.13
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: par-term-emu-core-rust>=0.42
|
|
22
|
+
Requires-Dist: pyside6>=6.11.1
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# pyqterminal
|
|
26
|
+
|
|
27
|
+
A cross-platform terminal emulator — Python frontend, Rust backend.
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<img src="screenshot.png" alt="pyqterminal Screenshot" width="720">
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- 🦀 **Rust-powered TUI** — parses VT520 escape sequences via the `vte` crate
|
|
36
|
+
- 🎨 **Full SGR support** — bold, italic, underline (5 styles: straight, double, curly, dotted, dashed), reverse video, dim, blink, strikethrough, hidden text
|
|
37
|
+
- 🌐 **CJK** — proper double-width rendering for Chinese, Japanese, Korean
|
|
38
|
+
- 🔣 **Nerd Font** — renders icon glyphs (Powerlevel10k, oh-my-zsh themes)
|
|
39
|
+
- 🖱️ **Mouse** — text selection with auto-copy, right-click context menu, scrollback with wheel
|
|
40
|
+
- 📋 **Clipboard** — Cmd+C / Cmd+V (macOS), Ctrl+Shift+C / Ctrl+Shift+V (Linux/Windows)
|
|
41
|
+
- 🔍 **Zoom** — Ctrl++/-/0 adjust font size (6–32pt)
|
|
42
|
+
- ⚡ **No buffering** — renders directly via QPainter, no QPixmap double-buffer (Retina-safe)
|
|
43
|
+
- 🪟 **Cross-platform** — macOS, Linux, Windows
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Requires Python 3.12.13+ and [`uv`](https://docs.astral.sh/uv/).
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/hanyoukuang/pyqterminal.git
|
|
51
|
+
cd pyqterminal
|
|
52
|
+
uv sync
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### Interactive mode (default)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv run python main.py
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Display-only mode
|
|
64
|
+
|
|
65
|
+
Use pyqterminal as a pure terminal display — pipe escape sequences from external sources (SSH, logs, etc.) without a local shell:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Pipe ANSI output to pyqterminal
|
|
69
|
+
echo -e '\x1b[31mHello\x1b[0m\n\x1b[7mReverse\x1b[0m' | uv run python main.py --display
|
|
70
|
+
|
|
71
|
+
# Display SSH session output
|
|
72
|
+
ssh user@host 2>&1 | uv run python main.py --display
|
|
73
|
+
|
|
74
|
+
# Programmatic usage
|
|
75
|
+
python -c "
|
|
76
|
+
from terminal.widget import TerminalWidget
|
|
77
|
+
widget = TerminalWidget(rows=24, cols=80, display_only=True)
|
|
78
|
+
widget.feed('\x1b[31mRed text\x1b[0m\n')
|
|
79
|
+
widget.feed('\x1b[47m\x1b[30mBlack on white\x1b[0m\n')
|
|
80
|
+
"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Keyboard shortcuts:
|
|
84
|
+
|
|
85
|
+
| Shortcut | Action |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `Cmd+C` / `Ctrl+Shift+C` | Copy selection |
|
|
88
|
+
| `Cmd+V` / `Ctrl+Shift+V` | Paste |
|
|
89
|
+
| `Ctrl++` / `Ctrl+-` / `Ctrl+0` | Zoom in / out / reset |
|
|
90
|
+
| `Shift+PageUp` / `Shift+PageDown` | Scroll back / forward |
|
|
91
|
+
| Mouse drag | Select text (auto-copied on release) |
|
|
92
|
+
| Mouse wheel | Scroll |
|
|
93
|
+
| Middle-click | Paste |
|
|
94
|
+
|
|
95
|
+
## Architecture
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Interactive: main.py → TerminalWidget → PtyTerminal (Rust, PTY)
|
|
99
|
+
Display-only: main.py → TerminalWidget → Terminal (Rust, headless)
|
|
100
|
+
├── InputHandler (QKeyEvent → terminal bytes)
|
|
101
|
+
└── QPainter (direct paintEvent rendering)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- **Backend:** [`par-term-emu-core-rust`](https://github.com/paulrobello/par-term-emu-core-rust) — Rust `vte` crate handles PTY, escape parsing, buffer, colors, cursor, scrollback
|
|
105
|
+
- **Frontend:** PySide6 `QPainter` — renders directly in `paintEvent()`, no QPixmap double-buffer (avoids Retina/HiDPI issues)
|
|
106
|
+
- **Input:** `InputHandler` maps `QKeyEvent` to terminal escape sequences
|
|
107
|
+
|
|
108
|
+
### Rendering pipeline
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Shell output → PTY → Rust vte parser → get_line_cells(row) → _render_cells() → QPainter
|
|
112
|
+
│
|
|
113
|
+
reverse swap · dim · bold/italic
|
|
114
|
+
hidden · blink · wide char (2 cols)
|
|
115
|
+
strikethrough · underline (5 styles)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT © 2026 Kaihong Han
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pyqterminal.egg-info/PKG-INFO
|
|
5
|
+
pyqterminal.egg-info/SOURCES.txt
|
|
6
|
+
pyqterminal.egg-info/dependency_links.txt
|
|
7
|
+
pyqterminal.egg-info/requires.txt
|
|
8
|
+
pyqterminal.egg-info/top_level.txt
|
|
9
|
+
terminal/__init__.py
|
|
10
|
+
terminal/input_handler.py
|
|
11
|
+
terminal/widget.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
terminal
|
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Encode QKeyEvent into terminal input bytes."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt
|
|
6
|
+
from PySide6.QtGui import QKeyEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InputHandler:
|
|
10
|
+
"""Convert Qt keyboard events to terminal escape sequences.
|
|
11
|
+
|
|
12
|
+
Handles:
|
|
13
|
+
- Plain text (including Unicode)
|
|
14
|
+
- Ctrl+key combinations (C0 control codes)
|
|
15
|
+
- Alt+key (ESC-prefixed)
|
|
16
|
+
- Special keys (arrows, home/end, F1-F12, etc.)
|
|
17
|
+
- Shift+Tab
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# Mapping of Qt key codes to terminal escape sequences
|
|
21
|
+
_KEY_SEQUENCES: dict[int, bytes] = {
|
|
22
|
+
Qt.Key_Up: b"\x1b[A",
|
|
23
|
+
Qt.Key_Down: b"\x1b[B",
|
|
24
|
+
Qt.Key_Right: b"\x1b[C",
|
|
25
|
+
Qt.Key_Left: b"\x1b[D",
|
|
26
|
+
Qt.Key_Home: b"\x1b[H",
|
|
27
|
+
Qt.Key_End: b"\x1b[F",
|
|
28
|
+
Qt.Key_PageUp: b"\x1b[5~",
|
|
29
|
+
Qt.Key_PageDown: b"\x1b[6~",
|
|
30
|
+
Qt.Key_Backspace: b"\x7f",
|
|
31
|
+
Qt.Key_Delete: b"\x1b[3~",
|
|
32
|
+
Qt.Key_Insert: b"\x1b[2~",
|
|
33
|
+
Qt.Key_Return: b"\r",
|
|
34
|
+
Qt.Key_Enter: b"\r",
|
|
35
|
+
Qt.Key_Tab: b"\t",
|
|
36
|
+
Qt.Key_Escape: b"\x1b",
|
|
37
|
+
Qt.Key_F1: b"\x1bOP",
|
|
38
|
+
Qt.Key_F2: b"\x1bOQ",
|
|
39
|
+
Qt.Key_F3: b"\x1bOR",
|
|
40
|
+
Qt.Key_F4: b"\x1bOS",
|
|
41
|
+
Qt.Key_F5: b"\x1b[15~",
|
|
42
|
+
Qt.Key_F6: b"\x1b[17~",
|
|
43
|
+
Qt.Key_F7: b"\x1b[18~",
|
|
44
|
+
Qt.Key_F8: b"\x1b[19~",
|
|
45
|
+
Qt.Key_F9: b"\x1b[20~",
|
|
46
|
+
Qt.Key_F10: b"\x1b[21~",
|
|
47
|
+
Qt.Key_F11: b"\x1b[23~",
|
|
48
|
+
Qt.Key_F12: b"\x1b[24~",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_MODIFIER_ONLY_KEYS = {
|
|
52
|
+
Qt.Key_Control, Qt.Key_Shift,
|
|
53
|
+
Qt.Key_Alt, Qt.Key_Meta,
|
|
54
|
+
Qt.Key_Super_L, Qt.Key_Super_R,
|
|
55
|
+
Qt.Key_CapsLock, Qt.Key_NumLock,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def encode(cls, event: QKeyEvent) -> bytes | None:
|
|
60
|
+
"""Encode a QKeyEvent into terminal input bytes.
|
|
61
|
+
|
|
62
|
+
Returns None if the key should not be sent to the PTY.
|
|
63
|
+
"""
|
|
64
|
+
key = event.key()
|
|
65
|
+
modifiers = event.modifiers()
|
|
66
|
+
text = event.text()
|
|
67
|
+
|
|
68
|
+
# Ignore modifier-only keys
|
|
69
|
+
if key in cls._MODIFIER_ONLY_KEYS:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
# --- Ctrl+key → C0 control codes (0x00-0x1F) ---
|
|
73
|
+
if sys.platform == "darwin":
|
|
74
|
+
ctrl_pressed = bool(modifiers & Qt.MetaModifier)
|
|
75
|
+
else:
|
|
76
|
+
ctrl_pressed = bool(modifiers & Qt.ControlModifier)
|
|
77
|
+
|
|
78
|
+
if ctrl_pressed:
|
|
79
|
+
if text:
|
|
80
|
+
return text.encode("utf-8")
|
|
81
|
+
if Qt.Key_A <= key <= Qt.Key_Z:
|
|
82
|
+
return bytes([key - Qt.Key_A + 1])
|
|
83
|
+
seq = cls._KEY_SEQUENCES.get(key)
|
|
84
|
+
if seq:
|
|
85
|
+
return seq
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
# --- Alt+key → ESC prefix ---
|
|
89
|
+
if modifiers & Qt.AltModifier:
|
|
90
|
+
if key in cls._KEY_SEQUENCES:
|
|
91
|
+
return b"\x1b" + cls._KEY_SEQUENCES[key]
|
|
92
|
+
if text:
|
|
93
|
+
return b"\x1b" + text.encode("utf-8")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# --- Shift+Tab → back-tab ---
|
|
97
|
+
if key == Qt.Key_Tab and modifiers & Qt.ShiftModifier:
|
|
98
|
+
return b"\x1b[Z"
|
|
99
|
+
|
|
100
|
+
# --- Special keys ---
|
|
101
|
+
seq = cls._KEY_SEQUENCES.get(key)
|
|
102
|
+
if seq is not None:
|
|
103
|
+
return seq
|
|
104
|
+
|
|
105
|
+
# --- Plain text (including with Shift for uppercase) ---
|
|
106
|
+
if text:
|
|
107
|
+
return text.encode("utf-8")
|
|
108
|
+
|
|
109
|
+
return None
|
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
from par_term_emu_core_rust import PtyTerminal, CursorStyle, UnderlineStyle, Terminal
|
|
2
|
+
from PySide6.QtWidgets import QWidget, QApplication, QMenu
|
|
3
|
+
from PySide6.QtCore import QTimer, Qt, QRectF
|
|
4
|
+
from PySide6.QtGui import (
|
|
5
|
+
QPainter, QFont, QFontMetrics, QColor,
|
|
6
|
+
QKeyEvent, QPaintEvent, QResizeEvent,
|
|
7
|
+
QWheelEvent, QMouseEvent, QAction,
|
|
8
|
+
QInputMethodEvent,
|
|
9
|
+
)
|
|
10
|
+
import sys
|
|
11
|
+
from .input_handler import InputHandler
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_FONT_CANDIDATES = (
|
|
15
|
+
"MesloLGS NF", "JetBrainsMono Nerd Font",
|
|
16
|
+
"FiraCode Nerd Font", "CaskaydiaCove Nerd Font",
|
|
17
|
+
"Hack Nerd Font", "DejaVuSansMono Nerd Font",
|
|
18
|
+
"SF Mono", "JetBrains Mono", "Fira Code",
|
|
19
|
+
"Menlo", "Courier New", "monospace",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pick_monospace_font(size: int = 13) -> QFont:
|
|
24
|
+
for family in _FONT_CANDIDATES:
|
|
25
|
+
font = QFont(family, size)
|
|
26
|
+
font.setStyleHint(QFont.Monospace)
|
|
27
|
+
font.setHintingPreference(QFont.PreferFullHinting)
|
|
28
|
+
fm = QFontMetrics(font)
|
|
29
|
+
if fm.horizontalAdvance("M") > 0:
|
|
30
|
+
return font
|
|
31
|
+
return QFont("monospace", size)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TerminalWidget(QWidget):
|
|
35
|
+
DEFAULT_FG = QColor(192, 192, 192)
|
|
36
|
+
DEFAULT_BG = QColor(0, 0, 0)
|
|
37
|
+
SELECTION_BG = QColor(80, 80, 80)
|
|
38
|
+
|
|
39
|
+
def __init__(self, parent=None, rows: int = 24, cols: int = 80,
|
|
40
|
+
display_only: bool = False):
|
|
41
|
+
super().__init__(parent)
|
|
42
|
+
|
|
43
|
+
self._font = _pick_monospace_font(13)
|
|
44
|
+
self._fm = QFontMetrics(self._font)
|
|
45
|
+
self._cell_w = int(max(self._fm.horizontalAdvance("M"), 1))
|
|
46
|
+
self._cell_h = int(max(self._fm.height(), 1))
|
|
47
|
+
|
|
48
|
+
self._rows = rows
|
|
49
|
+
self._cols = cols
|
|
50
|
+
self._scroll_offset = 0
|
|
51
|
+
self._wheel_accum = 0
|
|
52
|
+
self._unseen_output = False
|
|
53
|
+
self._cursor_visible = True
|
|
54
|
+
self._blink_visible = True
|
|
55
|
+
self._generation = 0
|
|
56
|
+
self._display_only = display_only
|
|
57
|
+
|
|
58
|
+
self._font_bold = QFont(self._font)
|
|
59
|
+
self._font_bold.setBold(True)
|
|
60
|
+
self._font_italic = QFont(self._font)
|
|
61
|
+
self._font_italic.setItalic(True)
|
|
62
|
+
self._font_bold_italic = QFont(self._font)
|
|
63
|
+
self._font_bold_italic.setBold(True)
|
|
64
|
+
self._font_bold_italic.setItalic(True)
|
|
65
|
+
|
|
66
|
+
if display_only:
|
|
67
|
+
self._term = Terminal(self._cols, self._rows)
|
|
68
|
+
else:
|
|
69
|
+
self._term = PtyTerminal(self._cols, self._rows)
|
|
70
|
+
|
|
71
|
+
self._sel_start: tuple[int, int] | None = None
|
|
72
|
+
self._sel_end: tuple[int, int] | None = None
|
|
73
|
+
self._selecting = False
|
|
74
|
+
self._preedit = ""
|
|
75
|
+
|
|
76
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
77
|
+
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
|
|
78
|
+
self.setAttribute(Qt.WA_InputMethodEnabled, True)
|
|
79
|
+
self.setMinimumSize(self._cell_w * 20, self._cell_h * 5)
|
|
80
|
+
self.setMouseTracking(True)
|
|
81
|
+
|
|
82
|
+
self._cursor_timer = QTimer(self)
|
|
83
|
+
self._cursor_timer.timeout.connect(self._toggle_cursor)
|
|
84
|
+
self._cursor_timer.start(530)
|
|
85
|
+
|
|
86
|
+
self._poll_timer: QTimer | None = None
|
|
87
|
+
if not display_only:
|
|
88
|
+
self._poll_timer = QTimer(self)
|
|
89
|
+
self._poll_timer.timeout.connect(self._poll_updates)
|
|
90
|
+
self._poll_timer.start(16)
|
|
91
|
+
|
|
92
|
+
def start_shell(self) -> None:
|
|
93
|
+
"""Start interactive shell (PtyTerminal mode only)."""
|
|
94
|
+
if self._display_only:
|
|
95
|
+
raise RuntimeError("start_shell() not available in display-only mode")
|
|
96
|
+
self._term.spawn_shell()
|
|
97
|
+
|
|
98
|
+
def feed(self, data: str) -> None:
|
|
99
|
+
"""Feed text/escape sequences for display (display-only mode).
|
|
100
|
+
|
|
101
|
+
Use this to pipe terminal output (e.g. from SSH) into the widget
|
|
102
|
+
for rendering without a local PTY.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
widget.feed("\\x1b[31mHello\\x1b[0m\\n")
|
|
106
|
+
"""
|
|
107
|
+
if not self._display_only:
|
|
108
|
+
raise RuntimeError("feed() only available in display-only mode")
|
|
109
|
+
self._term.process_str(data)
|
|
110
|
+
self.update()
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def rows(self) -> int:
|
|
114
|
+
return self._rows
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def cols(self) -> int:
|
|
118
|
+
return self._cols
|
|
119
|
+
|
|
120
|
+
# ── Polling ──────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def _poll_updates(self) -> None:
|
|
123
|
+
if self._display_only:
|
|
124
|
+
return
|
|
125
|
+
if self._term.has_updates_since(self._generation):
|
|
126
|
+
self._generation = self._term.update_generation()
|
|
127
|
+
if self._scroll_offset == 0:
|
|
128
|
+
self._unseen_output = False
|
|
129
|
+
self.update()
|
|
130
|
+
elif not self._unseen_output:
|
|
131
|
+
self._unseen_output = True
|
|
132
|
+
self.update()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
title = self._term.title()
|
|
136
|
+
if title and title != self.windowTitle():
|
|
137
|
+
self.setWindowTitle(title)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# ── Paint ────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
144
|
+
painter = QPainter(self)
|
|
145
|
+
painter.setFont(self._font)
|
|
146
|
+
painter.fillRect(self.rect(), self.DEFAULT_BG)
|
|
147
|
+
|
|
148
|
+
for display_row in range(self._rows):
|
|
149
|
+
self._draw_row(painter, display_row)
|
|
150
|
+
|
|
151
|
+
if self._scroll_offset == 0:
|
|
152
|
+
if self._preedit:
|
|
153
|
+
self._draw_preedit(painter)
|
|
154
|
+
else:
|
|
155
|
+
self._draw_cursor(painter)
|
|
156
|
+
|
|
157
|
+
if self._unseen_output and self._scroll_offset > 0:
|
|
158
|
+
indicator_w = self._cell_w * 3
|
|
159
|
+
indicator_h = 3
|
|
160
|
+
indicator_x = self._cols * self._cell_w - indicator_w
|
|
161
|
+
indicator_y = self._rows * self._cell_h - indicator_h
|
|
162
|
+
painter.fillRect(indicator_x, indicator_y,
|
|
163
|
+
indicator_w, indicator_h,
|
|
164
|
+
QColor(255, 200, 0))
|
|
165
|
+
|
|
166
|
+
painter.end()
|
|
167
|
+
|
|
168
|
+
def _draw_row(self, painter: QPainter, display_row: int) -> None:
|
|
169
|
+
y = display_row * self._cell_h
|
|
170
|
+
live_row = display_row - self._scroll_offset
|
|
171
|
+
|
|
172
|
+
if live_row < 0:
|
|
173
|
+
self._draw_scrollback_row(painter, display_row, y)
|
|
174
|
+
else:
|
|
175
|
+
self._draw_live_row(painter, live_row, y)
|
|
176
|
+
|
|
177
|
+
def _draw_scrollback_row(self, painter: QPainter,
|
|
178
|
+
display_row: int, y: int) -> None:
|
|
179
|
+
sb_idx = self._scroll_offset - display_row - 1
|
|
180
|
+
sb_len = self._term.scrollback_len()
|
|
181
|
+
if sb_idx < 0 or sb_idx >= sb_len:
|
|
182
|
+
return
|
|
183
|
+
try:
|
|
184
|
+
cells = self._term.scrollback_line(sb_idx)
|
|
185
|
+
except Exception:
|
|
186
|
+
return
|
|
187
|
+
if not cells:
|
|
188
|
+
return
|
|
189
|
+
self._render_cells(painter, cells, y, display_row)
|
|
190
|
+
|
|
191
|
+
def _draw_live_row(self, painter: QPainter,
|
|
192
|
+
live_row: int, y: int) -> None:
|
|
193
|
+
if live_row >= self._rows:
|
|
194
|
+
return
|
|
195
|
+
try:
|
|
196
|
+
cells = self._term.get_line_cells(live_row)
|
|
197
|
+
except Exception:
|
|
198
|
+
return
|
|
199
|
+
display_row = live_row + self._scroll_offset
|
|
200
|
+
self._render_cells(painter, cells, y, display_row)
|
|
201
|
+
|
|
202
|
+
def _render_cells(self, painter: QPainter, cells: list,
|
|
203
|
+
y: int, display_row: int) -> None:
|
|
204
|
+
cell_data: list[dict] = []
|
|
205
|
+
for col, (char, fg, bg, attrs) in enumerate(cells):
|
|
206
|
+
if col >= self._cols:
|
|
207
|
+
break
|
|
208
|
+
if attrs and attrs.wide_char_spacer:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
x = col * self._cell_w
|
|
212
|
+
is_wide = attrs and attrs.wide_char
|
|
213
|
+
cell_w = self._cell_w * 2 if is_wide else self._cell_w
|
|
214
|
+
is_space = not char or char == " "
|
|
215
|
+
|
|
216
|
+
is_reverse = attrs and attrs.reverse
|
|
217
|
+
if is_reverse:
|
|
218
|
+
eff_fg = bg if bg else (0, 0, 0)
|
|
219
|
+
eff_bg = fg if fg else (192, 192, 192)
|
|
220
|
+
else:
|
|
221
|
+
eff_fg = fg
|
|
222
|
+
eff_bg = bg
|
|
223
|
+
|
|
224
|
+
bg_rgb = eff_bg if eff_bg else (0, 0, 0)
|
|
225
|
+
selected = self._cell_in_selection(display_row, col)
|
|
226
|
+
|
|
227
|
+
cell_data.append({
|
|
228
|
+
'x': x, 'cell_w': cell_w, 'char': char,
|
|
229
|
+
'eff_fg': eff_fg, 'bg_rgb': bg_rgb,
|
|
230
|
+
'selected': selected, 'attrs': attrs,
|
|
231
|
+
'is_space': is_space,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
for d in cell_data:
|
|
235
|
+
if d['selected']:
|
|
236
|
+
painter.fillRect(d['x'], y, d['cell_w'], self._cell_h,
|
|
237
|
+
self.SELECTION_BG)
|
|
238
|
+
elif d['bg_rgb'] != (0, 0, 0):
|
|
239
|
+
painter.fillRect(d['x'], y, d['cell_w'], self._cell_h,
|
|
240
|
+
QColor(*d['bg_rgb']))
|
|
241
|
+
|
|
242
|
+
for d in cell_data:
|
|
243
|
+
attrs = d['attrs']
|
|
244
|
+
char = d['char']
|
|
245
|
+
x = d['x']
|
|
246
|
+
cell_w = d['cell_w']
|
|
247
|
+
|
|
248
|
+
if attrs and attrs.hidden:
|
|
249
|
+
continue
|
|
250
|
+
if d['is_space']:
|
|
251
|
+
continue
|
|
252
|
+
if attrs and attrs.blink and not self._blink_visible:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
fg_rgb = d['eff_fg'] if d['eff_fg'] else (192, 192, 192)
|
|
256
|
+
if attrs and attrs.dim:
|
|
257
|
+
fg_rgb = tuple(c // 2 for c in fg_rgb)
|
|
258
|
+
|
|
259
|
+
is_bold = attrs and attrs.bold
|
|
260
|
+
is_italic = attrs and attrs.italic
|
|
261
|
+
is_underline = attrs and attrs.underline
|
|
262
|
+
|
|
263
|
+
if is_bold and is_italic:
|
|
264
|
+
painter.setFont(self._font_bold_italic)
|
|
265
|
+
elif is_bold:
|
|
266
|
+
painter.setFont(self._font_bold)
|
|
267
|
+
elif is_italic:
|
|
268
|
+
painter.setFont(self._font_italic)
|
|
269
|
+
else:
|
|
270
|
+
painter.setFont(self._font)
|
|
271
|
+
|
|
272
|
+
painter.save()
|
|
273
|
+
|
|
274
|
+
is_block = len(char) == 1 and 0x2580 <= ord(char) <= 0x259F
|
|
275
|
+
if is_block:
|
|
276
|
+
painter.setClipRect(x, y, cell_w, self._cell_h)
|
|
277
|
+
else:
|
|
278
|
+
painter.setClipRect(x - 2, y - 2, cell_w + 4, self._cell_h + 4)
|
|
279
|
+
|
|
280
|
+
painter.setPen(QColor(*fg_rgb))
|
|
281
|
+
painter.drawText(x, int(y + self._fm.ascent()), char)
|
|
282
|
+
|
|
283
|
+
if attrs and attrs.strikethrough:
|
|
284
|
+
mid_y = y + self._cell_h // 2
|
|
285
|
+
painter.drawLine(x, mid_y, x + cell_w, mid_y)
|
|
286
|
+
|
|
287
|
+
if is_underline:
|
|
288
|
+
base_y = y + self._fm.ascent() + 2
|
|
289
|
+
ul_style = attrs.underline_style
|
|
290
|
+
self._draw_underline(painter, x, base_y, cell_w, ul_style)
|
|
291
|
+
|
|
292
|
+
painter.restore()
|
|
293
|
+
|
|
294
|
+
painter.setFont(self._font)
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _draw_underline(painter: QPainter, x: int, base_y: int,
|
|
298
|
+
cell_w: int, style) -> None:
|
|
299
|
+
"""Draw underline with style: Straight, Double, Curly, Dotted, Dashed."""
|
|
300
|
+
if style == UnderlineStyle.Double:
|
|
301
|
+
painter.drawLine(x, base_y - 1, x + cell_w, base_y - 1)
|
|
302
|
+
painter.drawLine(x, base_y + 1, x + cell_w, base_y + 1)
|
|
303
|
+
elif style == UnderlineStyle.Curly:
|
|
304
|
+
# Approximate with short dashes
|
|
305
|
+
pen = painter.pen()
|
|
306
|
+
pen.setStyle(Qt.DashLine)
|
|
307
|
+
painter.setPen(pen)
|
|
308
|
+
painter.drawLine(x, base_y, x + cell_w, base_y)
|
|
309
|
+
pen.setStyle(Qt.SolidLine)
|
|
310
|
+
painter.setPen(pen)
|
|
311
|
+
elif style == UnderlineStyle.Dotted:
|
|
312
|
+
pen = painter.pen()
|
|
313
|
+
pen.setStyle(Qt.DotLine)
|
|
314
|
+
painter.setPen(pen)
|
|
315
|
+
painter.drawLine(x, base_y, x + cell_w, base_y)
|
|
316
|
+
pen.setStyle(Qt.SolidLine)
|
|
317
|
+
painter.setPen(pen)
|
|
318
|
+
elif style == UnderlineStyle.Dashed:
|
|
319
|
+
pen = painter.pen()
|
|
320
|
+
pen.setStyle(Qt.DashLine)
|
|
321
|
+
painter.setPen(pen)
|
|
322
|
+
painter.drawLine(x, base_y, x + cell_w, base_y)
|
|
323
|
+
pen.setStyle(Qt.SolidLine)
|
|
324
|
+
painter.setPen(pen)
|
|
325
|
+
else:
|
|
326
|
+
# Straight (default) or None
|
|
327
|
+
painter.drawLine(x, base_y, x + cell_w, base_y)
|
|
328
|
+
|
|
329
|
+
def _draw_cursor(self, painter: QPainter) -> None:
|
|
330
|
+
if not self._cursor_visible:
|
|
331
|
+
return
|
|
332
|
+
try:
|
|
333
|
+
cx, cy = self._term.cursor_position()
|
|
334
|
+
style = self._term.cursor_style()
|
|
335
|
+
except Exception:
|
|
336
|
+
return
|
|
337
|
+
if not (0 <= cy < self._rows and 0 <= cx < self._cols):
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
x = cx * self._cell_w
|
|
341
|
+
y = cy * self._cell_h
|
|
342
|
+
|
|
343
|
+
_UNDERLINE = {CursorStyle.BlinkingUnderline, CursorStyle.SteadyUnderline}
|
|
344
|
+
_BAR = {CursorStyle.BlinkingBar, CursorStyle.SteadyBar}
|
|
345
|
+
|
|
346
|
+
if style in _UNDERLINE:
|
|
347
|
+
painter.fillRect(x, y + self._cell_h - 2, self._cell_w, 2,
|
|
348
|
+
self.DEFAULT_FG)
|
|
349
|
+
elif style in _BAR:
|
|
350
|
+
painter.fillRect(x, y, 2, self._cell_h, self.DEFAULT_FG)
|
|
351
|
+
else:
|
|
352
|
+
painter.fillRect(x, y, self._cell_w, self._cell_h, self.DEFAULT_FG)
|
|
353
|
+
|
|
354
|
+
def _draw_preedit(self, painter: QPainter) -> None:
|
|
355
|
+
try:
|
|
356
|
+
cx, cy = self._term.cursor_position()
|
|
357
|
+
except Exception:
|
|
358
|
+
return
|
|
359
|
+
if not (0 <= cy < self._rows and 0 <= cx < self._cols):
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
x = cx * self._cell_w
|
|
363
|
+
y = cy * self._cell_h
|
|
364
|
+
painter.setFont(self._font)
|
|
365
|
+
painter.setPen(self.DEFAULT_FG)
|
|
366
|
+
painter.drawText(x, int(y + self._fm.ascent()), self._preedit)
|
|
367
|
+
|
|
368
|
+
preedit_w = len(self._preedit) * self._cell_w
|
|
369
|
+
ul_y = y + self._cell_h - 2
|
|
370
|
+
painter.drawLine(x, int(ul_y), x + preedit_w, int(ul_y))
|
|
371
|
+
|
|
372
|
+
if self._cursor_visible:
|
|
373
|
+
cx_end = x + preedit_w
|
|
374
|
+
painter.fillRect(cx_end, y, self._cell_w, self._cell_h,
|
|
375
|
+
self.DEFAULT_FG)
|
|
376
|
+
|
|
377
|
+
# ── Selection ────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _in_range(val: int, a: int, b: int) -> bool:
|
|
381
|
+
lo, hi = (a, b) if a <= b else (b, a)
|
|
382
|
+
return lo <= val <= hi
|
|
383
|
+
|
|
384
|
+
def _cell_in_selection(self, row: int, col: int) -> bool:
|
|
385
|
+
if not self._sel_start or not self._sel_end:
|
|
386
|
+
return False
|
|
387
|
+
r1, c1 = self._sel_start
|
|
388
|
+
r2, c2 = self._sel_end
|
|
389
|
+
if r1 == r2:
|
|
390
|
+
return row == r1 and self._in_range(col, c1, c2)
|
|
391
|
+
if row < min(r1, r2) or row > max(r1, r2):
|
|
392
|
+
return False
|
|
393
|
+
if row == r1:
|
|
394
|
+
return col >= c1 if r1 <= r2 else col <= c1
|
|
395
|
+
if row == r2:
|
|
396
|
+
return col <= c2 if r1 <= r2 else col >= c2
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
def _selected_text(self) -> str:
|
|
400
|
+
if not self._sel_start or not self._sel_end:
|
|
401
|
+
return ""
|
|
402
|
+
r1, c1 = self._sel_start
|
|
403
|
+
r2, c2 = self._sel_end
|
|
404
|
+
if r1 > r2 or (r1 == r2 and c1 > c2):
|
|
405
|
+
r1, c1, r2, c2 = r2, c2, r1, c1
|
|
406
|
+
|
|
407
|
+
lines = []
|
|
408
|
+
for r in range(r1, r2 + 1):
|
|
409
|
+
live_row = r - self._scroll_offset
|
|
410
|
+
if live_row < 0:
|
|
411
|
+
sb_idx = self._scroll_offset - r - 1
|
|
412
|
+
try:
|
|
413
|
+
cells = self._term.scrollback_line(sb_idx)
|
|
414
|
+
text = "".join(c[0] for c in cells)
|
|
415
|
+
except Exception:
|
|
416
|
+
text = ""
|
|
417
|
+
else:
|
|
418
|
+
if live_row >= self._rows:
|
|
419
|
+
continue
|
|
420
|
+
try:
|
|
421
|
+
text = self._term.get_line(live_row)
|
|
422
|
+
except Exception:
|
|
423
|
+
text = ""
|
|
424
|
+
if not text:
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
sc = c1 if r == r1 else 0
|
|
428
|
+
ec = c2 + 1 if r == r2 else len(text)
|
|
429
|
+
if sc < len(text):
|
|
430
|
+
lines.append(text[sc:ec])
|
|
431
|
+
return "\n".join(lines)
|
|
432
|
+
|
|
433
|
+
def _copy_selection(self) -> None:
|
|
434
|
+
text = self._selected_text()
|
|
435
|
+
if text:
|
|
436
|
+
QApplication.clipboard().setText(text)
|
|
437
|
+
|
|
438
|
+
def _clear_selection(self) -> None:
|
|
439
|
+
self._sel_start = None
|
|
440
|
+
self._sel_end = None
|
|
441
|
+
self.update()
|
|
442
|
+
|
|
443
|
+
# ── Mouse events ─────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
446
|
+
if event.button() == Qt.LeftButton:
|
|
447
|
+
col = int(event.position().x() // self._cell_w)
|
|
448
|
+
row = int(event.position().y() // self._cell_h)
|
|
449
|
+
self._clear_selection()
|
|
450
|
+
self._sel_start = (row, col)
|
|
451
|
+
self._sel_end = (row, col)
|
|
452
|
+
self._selecting = True
|
|
453
|
+
self.setCursor(Qt.IBeamCursor)
|
|
454
|
+
elif event.button() == Qt.MiddleButton:
|
|
455
|
+
if not self._display_only:
|
|
456
|
+
text = QApplication.clipboard().text()
|
|
457
|
+
if text:
|
|
458
|
+
self._term.write_str(text)
|
|
459
|
+
else:
|
|
460
|
+
super().mousePressEvent(event)
|
|
461
|
+
|
|
462
|
+
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
|
463
|
+
if self._selecting:
|
|
464
|
+
col = max(0, min(self._cols - 1,
|
|
465
|
+
int(event.position().x() // self._cell_w)))
|
|
466
|
+
row = max(0, min(self._rows - 1,
|
|
467
|
+
int(event.position().y() // self._cell_h)))
|
|
468
|
+
self._sel_end = (row, col)
|
|
469
|
+
self.update()
|
|
470
|
+
else:
|
|
471
|
+
super().mouseMoveEvent(event)
|
|
472
|
+
|
|
473
|
+
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
|
474
|
+
if event.button() == Qt.LeftButton and self._selecting:
|
|
475
|
+
self._selecting = False
|
|
476
|
+
self.setCursor(Qt.ArrowCursor)
|
|
477
|
+
if self._sel_start == self._sel_end:
|
|
478
|
+
self._clear_selection()
|
|
479
|
+
else:
|
|
480
|
+
self._copy_selection()
|
|
481
|
+
else:
|
|
482
|
+
super().mouseReleaseEvent(event)
|
|
483
|
+
|
|
484
|
+
def contextMenuEvent(self, event) -> None:
|
|
485
|
+
menu = QMenu(self)
|
|
486
|
+
|
|
487
|
+
copy_action = QAction("Copy", menu)
|
|
488
|
+
copy_action.setShortcut("Ctrl+Shift+C")
|
|
489
|
+
copy_action.triggered.connect(self._copy_selection)
|
|
490
|
+
copy_action.setEnabled(bool(self._sel_start))
|
|
491
|
+
menu.addAction(copy_action)
|
|
492
|
+
|
|
493
|
+
paste_action = QAction("Paste", menu)
|
|
494
|
+
paste_action.setShortcut("Ctrl+Shift+V")
|
|
495
|
+
paste_action.triggered.connect(self._paste_clipboard)
|
|
496
|
+
menu.addAction(paste_action)
|
|
497
|
+
|
|
498
|
+
menu.addSeparator()
|
|
499
|
+
|
|
500
|
+
zoom_in = QAction("Zoom In", menu)
|
|
501
|
+
zoom_in.setShortcut("Ctrl++")
|
|
502
|
+
zoom_in.triggered.connect(lambda: self._change_font_size(1))
|
|
503
|
+
menu.addAction(zoom_in)
|
|
504
|
+
|
|
505
|
+
zoom_out = QAction("Zoom Out", menu)
|
|
506
|
+
zoom_out.setShortcut("Ctrl+-")
|
|
507
|
+
zoom_out.triggered.connect(lambda: self._change_font_size(-1))
|
|
508
|
+
menu.addAction(zoom_out)
|
|
509
|
+
|
|
510
|
+
zoom_reset = QAction("Reset Zoom", menu)
|
|
511
|
+
zoom_reset.setShortcut("Ctrl+0")
|
|
512
|
+
zoom_reset.triggered.connect(lambda: self._change_font_size(
|
|
513
|
+
13 - self._font.pointSize()))
|
|
514
|
+
menu.addAction(zoom_reset)
|
|
515
|
+
|
|
516
|
+
menu.exec(event.globalPos())
|
|
517
|
+
|
|
518
|
+
def _paste_clipboard(self) -> None:
|
|
519
|
+
if self._display_only:
|
|
520
|
+
return
|
|
521
|
+
text = QApplication.clipboard().text()
|
|
522
|
+
if text:
|
|
523
|
+
self._term.write_str(text)
|
|
524
|
+
|
|
525
|
+
def wheelEvent(self, event: QWheelEvent) -> None:
|
|
526
|
+
if self._display_only:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
self._wheel_accum += event.angleDelta().y()
|
|
530
|
+
threshold = self._cell_h
|
|
531
|
+
lines = int(self._wheel_accum // threshold)
|
|
532
|
+
if lines == 0:
|
|
533
|
+
return
|
|
534
|
+
self._wheel_accum %= threshold
|
|
535
|
+
|
|
536
|
+
if self._term.mouse_mode() != "off":
|
|
537
|
+
self._send_mouse_wheel(event, lines)
|
|
538
|
+
else:
|
|
539
|
+
max_scroll = max(self._term.scrollback_len(), self._rows * 100)
|
|
540
|
+
self._scroll_offset = max(0, min(max_scroll,
|
|
541
|
+
self._scroll_offset - lines))
|
|
542
|
+
self.update()
|
|
543
|
+
|
|
544
|
+
def _send_mouse_wheel(self, event: QWheelEvent, lines: int) -> None:
|
|
545
|
+
col = int(event.position().x() // self._cell_w)
|
|
546
|
+
row = int(event.position().y() // self._cell_h)
|
|
547
|
+
button = 64 if lines > 0 else 65
|
|
548
|
+
col = min(col, 222)
|
|
549
|
+
row = min(row, 222)
|
|
550
|
+
for _ in range(abs(lines)):
|
|
551
|
+
seq = b"\x1b[M" + bytes([button + 32]) + bytes([col + 32]) + bytes([row + 32])
|
|
552
|
+
self._term.write(seq)
|
|
553
|
+
|
|
554
|
+
# ── Keyboard ─────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
557
|
+
key = event.key()
|
|
558
|
+
mods = event.modifiers()
|
|
559
|
+
|
|
560
|
+
zoom_mod = bool(mods & Qt.ControlModifier)
|
|
561
|
+
if sys.platform != "darwin":
|
|
562
|
+
zoom_mod = zoom_mod and bool(mods & Qt.ShiftModifier)
|
|
563
|
+
if zoom_mod and key in (Qt.Key_Plus, Qt.Key_Equal, Qt.Key_Minus):
|
|
564
|
+
delta = 1 if key != Qt.Key_Minus else -1
|
|
565
|
+
self._change_font_size(delta)
|
|
566
|
+
return
|
|
567
|
+
if zoom_mod and key == Qt.Key_0:
|
|
568
|
+
self._change_font_size(13 - self._font.pointSize())
|
|
569
|
+
return
|
|
570
|
+
if key == Qt.Key_PageUp and mods & Qt.ShiftModifier:
|
|
571
|
+
max_scroll = max(self._term.scrollback_len(), self._rows * 100)
|
|
572
|
+
self._scroll_offset = min(max_scroll,
|
|
573
|
+
self._scroll_offset + self._rows // 2)
|
|
574
|
+
self.update()
|
|
575
|
+
return
|
|
576
|
+
if key == Qt.Key_PageDown and mods & Qt.ShiftModifier:
|
|
577
|
+
self._scroll_offset = max(0,
|
|
578
|
+
self._scroll_offset - self._rows // 2)
|
|
579
|
+
self.update()
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
# Copy: Cmd+C (macOS) or Ctrl+Shift+C
|
|
583
|
+
copy_key = key == Qt.Key_C
|
|
584
|
+
copy_mod = bool(mods & Qt.ControlModifier)
|
|
585
|
+
if sys.platform == "darwin":
|
|
586
|
+
is_copy = copy_key and copy_mod and not (mods & Qt.ShiftModifier)
|
|
587
|
+
else:
|
|
588
|
+
is_copy = copy_key and copy_mod and bool(mods & Qt.ShiftModifier)
|
|
589
|
+
if is_copy:
|
|
590
|
+
self._copy_selection()
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
# Paste: Cmd+V (macOS) or Ctrl+Shift+V
|
|
594
|
+
paste_key = key == Qt.Key_V
|
|
595
|
+
paste_mod = bool(mods & Qt.ControlModifier)
|
|
596
|
+
if sys.platform == "darwin":
|
|
597
|
+
is_paste = paste_key and paste_mod and not (mods & Qt.ShiftModifier)
|
|
598
|
+
else:
|
|
599
|
+
is_paste = paste_key and paste_mod and bool(mods & Qt.ShiftModifier)
|
|
600
|
+
if is_paste:
|
|
601
|
+
self._clear_selection()
|
|
602
|
+
if not self._display_only:
|
|
603
|
+
clipboard = QApplication.clipboard()
|
|
604
|
+
text = clipboard.text()
|
|
605
|
+
if text:
|
|
606
|
+
self._term.write_str(text)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
if not self._display_only:
|
|
610
|
+
data = InputHandler.encode(event)
|
|
611
|
+
if data:
|
|
612
|
+
self._term.write(data)
|
|
613
|
+
|
|
614
|
+
def inputMethodEvent(self, event: QInputMethodEvent) -> None:
|
|
615
|
+
commit = event.commitString()
|
|
616
|
+
if commit:
|
|
617
|
+
self._term.write_str(commit)
|
|
618
|
+
self._preedit = event.preeditString()
|
|
619
|
+
self.update()
|
|
620
|
+
|
|
621
|
+
def inputMethodQuery(self, query: Qt.InputMethodQuery):
|
|
622
|
+
if query == Qt.ImCursorRectangle:
|
|
623
|
+
try:
|
|
624
|
+
cx, cy = self._term.cursor_position()
|
|
625
|
+
except Exception:
|
|
626
|
+
return QRectF()
|
|
627
|
+
x = cx * self._cell_w
|
|
628
|
+
y = cy * self._cell_h
|
|
629
|
+
return QRectF(x, y, self._cell_w, self._cell_h)
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
# ── Resize ────────────────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
635
|
+
new_cols = max(1, self.width() // self._cell_w)
|
|
636
|
+
new_rows = max(1, self.height() // self._cell_h)
|
|
637
|
+
|
|
638
|
+
if new_cols != self._cols or new_rows != self._rows:
|
|
639
|
+
self._cols = new_cols
|
|
640
|
+
self._rows = new_rows
|
|
641
|
+
self._term.resize(self._cols, self._rows)
|
|
642
|
+
|
|
643
|
+
self.update()
|
|
644
|
+
|
|
645
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
def _toggle_cursor(self) -> None:
|
|
648
|
+
self._cursor_visible = not self._cursor_visible
|
|
649
|
+
self._blink_visible = not self._blink_visible
|
|
650
|
+
self.update()
|
|
651
|
+
|
|
652
|
+
def _change_font_size(self, delta: int) -> None:
|
|
653
|
+
size = max(6, min(32, self._font.pointSize() + delta))
|
|
654
|
+
self._font = _pick_monospace_font(size)
|
|
655
|
+
self._fm = QFontMetrics(self._font)
|
|
656
|
+
self._cell_w = int(max(self._fm.horizontalAdvance("M"), 1))
|
|
657
|
+
self._cell_h = int(max(self._fm.height(), 1))
|
|
658
|
+
|
|
659
|
+
self._font_bold = QFont(self._font)
|
|
660
|
+
self._font_bold.setBold(True)
|
|
661
|
+
self._font_italic = QFont(self._font)
|
|
662
|
+
self._font_italic.setItalic(True)
|
|
663
|
+
self._font_bold_italic = QFont(self._font)
|
|
664
|
+
self._font_bold_italic.setBold(True)
|
|
665
|
+
self._font_bold_italic.setItalic(True)
|
|
666
|
+
|
|
667
|
+
new_cols = max(1, self.width() // self._cell_w)
|
|
668
|
+
new_rows = max(1, self.height() // self._cell_h)
|
|
669
|
+
self._cols = new_cols
|
|
670
|
+
self._rows = new_rows
|
|
671
|
+
self._term.resize(self._cols, self._rows)
|
|
672
|
+
self.update()
|