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.
@@ -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,2 @@
1
+ par-term-emu-core-rust>=0.42
2
+ pyside6>=6.11.1
@@ -0,0 +1 @@
1
+ terminal
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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()