forked from Einswilli/KivymdStudio
-
Notifications
You must be signed in to change notification settings - Fork 0
/
MiniTerminal.py
222 lines (181 loc) · 8.5 KB
/
MiniTerminal.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""Primitive terminal emulator example made from a PyQt QTextEdit widget."""
import fcntl, locale, os, pty, struct, sys, termios
import subprocess
from PySide2 import QtWidgets
from PySide2.QtCore import QObject # nosec
# Quick hack to limit the scope of the PyLint warning disabler
try:
# pylint: disable=no-name-in-module
from PyQt5.QtCore import Qt, QSocketNotifier # type: ignore
from PyQt5.QtGui import QFont, QPalette, QTextCursor # type: ignore
from PyQt5.QtWidgets import QApplication, QStyle, QTextEdit # type: ignore
from PySide2.QtGui import QGuiApplication
from PySide2.QtCore import *
except ImportError:
raise
# It's good practice to put these sorts of things in constants at the top
# rather than embedding them in your code
DEFAULT_TTY_CMD = ['/bin/bash']
DEFAULT_COLS = 80
DEFAULT_ROWS = 25
# NOTE: You can use any QColor instance, not just the predefined ones.
DEFAULT_TTY_FONT = QFont('Noto', 16)
DEFAULT_TTY_FG = Qt.lightGray
DEFAULT_TTY_BG = Qt.black
# The character to use as a reference point when converting between pixel and
# character cell dimensions in the presence of a non-fixed-width font
REFERENCE_CHAR = 'W'
class Terminal(QObject):
"""Simple TERM=tty terminal emulator widget
(Uses QTextEdit rather than QPlainTextEdit to leave the capability open to
support colors.)
"""
# Used to block the user from backspacing more characters than they
# typed since last pressing Enter
backspace_budget = 0
# Persistent handle for the master side of the PTY and its QSocketNotifier
pty_m = None
subproc = None
notifier = None
te=QTextEdit(parent=None)
def __init__(self):
QObject().__init__(self)
# Do due diligence to figure out what character coding child
# applications will expect to speak
self.codec = locale.getpreferredencoding()
# Customize the look and feel
pal = self.te.palette()
pal.setColor(QPalette.Base, DEFAULT_TTY_BG)
pal.setColor(QPalette.Text, DEFAULT_TTY_FG)
self.te.setPalette(pal)
self.te.setFont(DEFAULT_TTY_FONT)
# Disable the widget's built-in editing support rather than looking
# into how to constrain it. (Quick hack which means we have to provide
# our own visible cursor if we want one)
self.te.setReadOnly(True)
def cb_echo(self, pty_m):
"""Display output that arrives from the PTY"""
# Read pending data or assume the child exited if we can't
# (Not technically the proper way to detect child exit, but it works)
try:
# Use 'replace' as a not-ideal-but-better-than-nothing way to deal
# with bytes that aren't valid in the chosen encoding.
child_output = os.read(self.pty_m, 1024).decode(
self.codec, 'replace')
except OSError:
# Ask the event loop to exit and then return to it
QApplication.instance().quit()
return
# Insert the output at the end and scroll to the bottom
self.te.moveCursor(QTextCursor.End)
self.te.insertPlainText(child_output)
scroller = self.te.verticalScrollBar()
scroller.setValue(scroller.maximum())
def keyPressEvent(self, event):
"""Handler for all key presses delivered while the widget has focus"""
char = event.text()
# Move the cursor to the end
self.te.moveCursor(QTextCursor.End)
cursor = self.te.textCursor()
# If the character isn't a control code of some sort,
# then echo it to the terminal screen.
#
# (The length check is necessary to ignore empty strings which
# count as printable but break backspace_budget)
#
# FIXME: I'm almost certain backspace_budget will break here if you
# feed in multi-codepoint grapheme clusters.
if char and (char.isprintable() or char == '\r'):
cursor.insertText(char)
self.backspace_budget += len(char)
# Implement backspacing characters we typed
if char == '\x08' and self.backspace_budget > 0: # Backspace
cursor.deletePreviousChar()
self.backspace_budget -= 1
elif char == '\r': # Enter
self.backspace_budget = 0
# Regardless of what we do, send the character to the PTY
# (Let the kernel's PTY implementation do most of the heavy lifting)
os.write(self.pty_m, char.encode(self.codec))
# Scroll to the bottom on keypress, but only after modifying the
# contents to make sure we don't scroll to where the bottom was before
# word-wrap potentially added more lines
scroller = self.te.verticalScrollBar()
scroller.setValue(scroller.maximum())
def resizeEvent(self, event):
"""Handler to announce terminal size changes to child processes"""
# Call Qt's built-in resize event handler
super(Terminal, self).resizeEvent(event)
fontMetrics = self.te.fontMetrics()
win_size_px = self.te.size()
char_width = fontMetrics.boundingRect(REFERENCE_CHAR).width()
# Subtract the space a scrollbar will take from the usable width
usable_width = (win_size_px.width() - QApplication.instance().style()
.pixelMetric(QStyle.PM_ScrollBarExtent))
# Use integer division (rounding down in this case) to find dimensions
cols = usable_width // char_width
rows = win_size_px.height() // fontMetrics.height()
# Announce the change to the PTY
fcntl.ioctl(self.pty_m, termios.TIOCSWINSZ,
struct.pack("HHHH", rows, cols, 0, 0))
# As a quick hack, scroll to the bottom on resize
# (The proper solution would be to preserve scroll position no matter
# what it is)
scroller = self.te.verticalScrollBar()
scroller.setValue(scroller.maximum())
def spawn(self, argv):
"""Launch a child process in the terminal"""
# Clean up after any previous spawn() runs
# TODO: Need to reap zombie children
# XXX: Kill existing children if spawn is called a second time?
if self.pty_m:
self.pty_m.close()
if self.notifier:
self.notifier.disconnect()
# Create a new PTY with both ends open
self.pty_m, pty_s = pty.openpty()
# Reset this, since it's PTY-specific
self.backspace_budget = 0
# Stop the PTY from echoing back what we type on this end
term_attrs = termios.tcgetattr(pty_s)
term_attrs[3] &= ~termios.ECHO
termios.tcsetattr(pty_s, termios.TCSANOW, term_attrs)
# Tell child processes that we're a dumb terminal that doesn't
# understand colour or cursor movement escape sequences
#
# (This will prevent well-behaved processes from emitting colour codes
# and will cause things which *require* cursor control like mutt and
# ncdu to error out on startup with "Error opening terminal: tty")
child_env = os.environ.copy()
child_env['TERM'] = 'tty'
# Launch the subprocess
# FIXME: Keep a reference so we can reap zombie processes
subprocess.Popen(argv, # nosec
stdin=pty_s, stdout=pty_s, stderr=pty_s,
env=child_env,
preexec_fn=os.setsid)
# Close the child side of the PTY so that we can detect when to exit
os.close(pty_s)
# Hook up an event handler for data waiting on the PTY
# (Because I didn't feel like looking into whether QProcess can be
# integrated with PTYs as a subprocess.Popen alternative)
self.notifier = QSocketNotifier(
self.pty_m, QSocketNotifier.Read, self)
self.notifier.activated.connect(self.cb_echo)
# Run this code if the file is launched from the command line but not if
# it is `import`ed as a dependency.
if __name__ == '__main__':
app = QGuiApplication(sys.argv)
#engine = QQmlApplicationEngine()
mainwin = Terminal()
# Cheap hack to estimate what 80x25 should be in pixels and resize to it
fontMetrics = mainwin.fontMetrics()
target_width = (fontMetrics.boundingRect(
REFERENCE_CHAR * DEFAULT_COLS
).width() + app.style().pixelMetric(QStyle.PM_ScrollBarExtent))
mainwin.resize(target_width, fontMetrics.height() * DEFAULT_ROWS)
# Launch DEFAULT_TTY_CMD in the terminal
mainwin.spawn(DEFAULT_TTY_CMD)
# Take advantage of how Qt lets any widget be a top-level window
mainwin.show()
app.exec_()