├── README.md ├── screenshot.png ├── slowpty.py ├── tty33wrap.sh └── ttyemu.py /README.md: -------------------------------------------------------------------------------- 1 | Terminal emulator for ASR-33. 2 | 3 | ![screenshot](screenshot.png) 4 | 5 | Features: 6 | 7 | - Pygame and Tkinter frontends. 8 | 9 | - Backends for pty (Linux/Mac) and ssh (Paramiko library) 10 | 11 | - Limits output to an authentic 10 characters per second. Hit F5 to make it go 12 | faster (toggle on tkinter frontend, hold on pygame) 13 | 14 | - Scrolling (with page up and down - tkinter frontend has a scrollbar) 15 | 16 | - Output a form feed to clear everything 17 | 18 | Various bugs and to-dos: 19 | 20 | - No user interface to select between frontends and backends. For now, edit the 21 | script. 22 | 23 | - Speed throttling (whether through the backend or throttle.py) does not work 24 | well on Linux. It works on WSL, and the last time I checked this technique 25 | worked on macOS. You'll still get the 10-chars-per-second output, but 26 | interrupting long outputs won't work. 27 | 28 | - Most of the fun termios functions (echoprt, echok, kill, reprint, discard) 29 | don't work on WSL 30 | 31 | - Add tty-37 support (half lines, reverse line feed, and lowercase). For now, 32 | the forced uppercase can be disabled by editing the upper() function 33 | 34 | - Add backends for wslbridge and msys/cygwin. 35 | 36 | - Improve graphics, better font, allow "ink spread" for overstruck bold. 37 | 38 | - Simulate classical 'stty lcase' line discipline for input of upper/lowercase 39 | letters and `` `{|}~`` (part or all of this are broken in modern OSes) 40 | 41 | - Discard and regenerate scrollback to limit memory usage. The AbstractLine 42 | class I created should be useful for this, but nothing is hooked up. 43 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Random832/ttyemu/e2941066291ab0e9105553cd6272f6117a9083f2/screenshot.png -------------------------------------------------------------------------------- /slowpty.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | "Simple script to reduce output speed." 3 | # Doing it inside the terminal emulator alone causes serious problems for 4 | # non-pty backends. This allows e.g. interrupting a long output to work more 5 | # like how it would have on classic systems. Works in WSL and Mac, but not 6 | # Linux. There seems to be no way to get nice behavior at all on Linux. 7 | 8 | import pty 9 | import os 10 | import select 11 | import time 12 | import sys 13 | import termios 14 | import tty 15 | 16 | # pylint: disable=invalid-name,protected-access,broad-except 17 | def main(): 18 | "Main function" 19 | attr = termios.tcgetattr(0) 20 | pid, fd = pty.fork() 21 | cmd = sys.argv[1:] or ['sh'] 22 | CHARS_PER_SEC = 10 23 | CHAR_DELAY = 1/CHARS_PER_SEC 24 | if pid == 0: 25 | try: 26 | attr[4] = attr[5] = termios.B110 27 | termios.tcsetattr(0, termios.TCSAFLUSH, attr) 28 | os.execvp(cmd[0], cmd) 29 | except Exception as e: 30 | print(e) 31 | os._exit(126) 32 | os._exit(126) 33 | 34 | tty.setraw(0) 35 | try: 36 | while True: 37 | rl = select.select((0, fd), (), ())[0] 38 | if 0 in rl: 39 | data = os.read(0, 64) 40 | os.write(fd, data) 41 | if fd in rl: 42 | data = os.read(fd, 1) 43 | os.write(1, data) 44 | time.sleep(0.1) 45 | finally: 46 | termios.tcsetattr(0, termios.TCSAFLUSH, attr) 47 | 48 | main() 49 | -------------------------------------------------------------------------------- /tty33wrap.sh: -------------------------------------------------------------------------------- 1 | export TERM=tty33 2 | export TERMCAP='tty33:bs:hc:os:xo:co#72:ht#8:bl=^G:cr=^M:do=^J:sf=^J:le=^H: 3 | tty37:hd=\E9:hu=\E8:up=\E7:tc=tty33:' 4 | export LS_COLORS='' 5 | stty -echoe -echoke echoprt echok 6 | # xcase is broken on linux, so iuclc makes it impossible to use uppercase 7 | # unclear if olcuc is needed for tty-33. it doesn't translate {|}~ anyway. 8 | if [ $# -gt 0 ]; then 9 | exec python3 throttle.py "$@" 10 | else 11 | exec python3 throttle.py sh 12 | fi 13 | -------------------------------------------------------------------------------- /ttyemu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | "ASR-33 terminal emulator" 3 | import sys 4 | import time 5 | import threading 6 | import tkinter 7 | import tkinter.font 8 | import abc 9 | import subprocess 10 | import os 11 | import shlex 12 | try: 13 | import pty 14 | import termios 15 | except ImportError: 16 | pass 17 | try: 18 | import paramiko 19 | except ImportError: 20 | pass 21 | try: 22 | import pygame 23 | except ImportError: 24 | pass 25 | 26 | COLUMNS = 72 27 | TEXT_COLOR = (0x33, 0x33, 0x33) 28 | 29 | def upper(char): 30 | "Converts a character to uppercase in the dumbest way possible." 31 | char = ord(char) 32 | char = (char - 32) & 127 33 | if char > 64: 34 | char = 32 | (char & 31) 35 | return chr(char+32) 36 | 37 | def background_color(): 38 | "Return a background color." 39 | # Mainly for debug purposes, each new surface 40 | # gets a new background color in debug mode. 41 | #r = random.randrange(192, 256) 42 | #g = random.randrange(192, 256) 43 | #b = random.randrange(192, 256) 44 | #return (r, g, b) 45 | return (0xff, 0xee, 0xdd) 46 | 47 | 48 | class AbstractLine: 49 | "Efficiently represent a line of text with overstrikes" 50 | def __init__(self): 51 | self.extents = [] 52 | 53 | def place_char(self, column, char): 54 | "Insert a character into an available extent." 55 | if char == ' ': 56 | return 57 | for i, (begin, text) in enumerate(self.extents): 58 | end = begin+len(text) 59 | if end == column: 60 | text = text + char 61 | self.extents[i] = (begin, text) 62 | elif end + 1 == column: 63 | text = text + ' ' + char 64 | self.extents[i] = (begin, text) 65 | # extend left? replace spaces? 66 | self.extents.append((column, char)) 67 | 68 | def string_test(self, chars, column=0): 69 | """ 70 | Insert a sequence of character, interpreting backspace, tab, and 71 | carriage return. Return value is final column. 72 | """ 73 | for char in chars: 74 | if char == '\t': 75 | column = (column + 8) & -8 76 | elif char == '\r': 77 | column = 0 78 | elif char == '\b': 79 | column -= 1 80 | else: 81 | self.place_char(column, char) 82 | column += 1 83 | if column > 71: 84 | column = 71 85 | if column < 0: 86 | column = 0 87 | return column 88 | 89 | @staticmethod 90 | def unit_test(chars): 91 | "Test function" 92 | print("Test of", repr(chars)) 93 | line = AbstractLine() 94 | line.string_test(chars) 95 | for begin, text in line.extents: 96 | print(" ", begin, repr(text)) 97 | 98 | SLOP = 4 99 | class TkinterFrontend: 100 | "Front-end using tkinter" 101 | # pylint: disable=too-many-instance-attributes 102 | def __init__(self, terminal=None): 103 | self.fg='#%02x%02x%02x' % TEXT_COLOR 104 | bg='#%02x%02x%02x' % background_color() 105 | self.terminal = terminal 106 | self.root = tkinter.Tk() 107 | if 'Teleprinter' in tkinter.font.families(self.root): 108 | # http://www.zanzig.com/download/ 109 | font = tkinter.font.Font(family='Teleprinter').actual() 110 | font['weight'] = 'bold' 111 | elif 'TELETYPE 1945-1985' in tkinter.font.families(self.root): 112 | # https://www.dafont.com/teletype-1945-1985.font 113 | font = tkinter.font.Font(family='TELETYPE 1945-1985').actual() 114 | else: 115 | font = tkinter.font.nametofont('TkFixedFont').actual() 116 | font['size'] = 16 117 | font = tkinter.font.Font(**font) 118 | self.font = font 119 | self.font_width = font.measure('X') 120 | self.font_height = self.font_width * 10 / 6 121 | self.canvas = tkinter.Canvas( 122 | self.root, 123 | bg=bg, 124 | height=24 * self.font_height + SLOP*2, 125 | width=COLUMNS * self.font_width + SLOP*2) 126 | bbox = (0, 0, self.font_width, self.font_height) 127 | self.cursor_id = self.canvas.create_rectangle(bbox) 128 | self.root.bind('', self.key) 129 | xscrollbar = tkinter.Scrollbar(self.root, orient='horizontal') 130 | xscrollbar.grid(row=1, column=0, sticky='ew') 131 | yscrollbar = tkinter.Scrollbar(self.root) 132 | yscrollbar.grid(row=0, column=1, sticky='ns') 133 | self.canvas.grid(row=0, column=0, sticky='nsew') 134 | self.root.grid_rowconfigure(0, weight=1) 135 | self.root.grid_columnconfigure(0, weight=1) 136 | self.max_line = 0 137 | self.canvas.config( 138 | xscrollcommand=xscrollbar.set, 139 | yscrollcommand=yscrollbar.set, 140 | offset='%d,%d'%(-SLOP,-SLOP), 141 | scrollregion=(-SLOP, -SLOP, COLUMNS*self.font_width+SLOP, self.font_height+SLOP), 142 | ) 143 | xscrollbar.config(command=self.canvas.xview) 144 | yscrollbar.config(command=self.canvas.yview) 145 | 146 | def key(self, event): 147 | "Handle a keyboard event" 148 | #print(event) 149 | if event.keysym == 'F5': 150 | self.terminal.backend.fast_mode ^= True 151 | elif event.keysym == 'Prior': 152 | self.canvas.yview_scroll(-1, 'pages') 153 | elif event.keysym == 'Next': 154 | self.canvas.yview_scroll(1, 'pages') 155 | elif event.char: 156 | if len(event.char) > 1 or ord(event.char) > 0xF000: 157 | # weird mac tk stuff 158 | pass 159 | else: 160 | self.terminal.backend.write_char(event.char) 161 | 162 | def postchars(self, chars): 163 | "Relay the characters from the backend to the controller" 164 | self.terminal.output_chars(chars) 165 | 166 | # pylint: disable=invalid-name 167 | def draw_char(self, line, column, char): 168 | "Draw a character on the screen" 169 | x = column * self.font_width 170 | y = line * self.font_height 171 | #print("drawing char", repr(char), "at", (x, y)) 172 | self.canvas.create_text( 173 | (x, y), 174 | text=char, 175 | fill=self.fg, 176 | anchor='nw', 177 | font=self.font) 178 | # Yes, this creates an object for every character. Yes, it is 179 | # disgusting, and gets hideously slow after a few thousand lines of 180 | # output. The Tkinter front end is mainly intended for testing. 181 | if self.max_line < line: 182 | self.max_line = line 183 | 184 | def lines_screen(self): 185 | "Dummy" 186 | return self.max_line + 1 187 | 188 | # pylint: disable=invalid-name 189 | # pylint: disable=unused-argument 190 | def refresh_screen(self, scroll_base, cursor_line, cursor_column): 191 | "Tkinter refresh method mostly just moves the cursor" 192 | x0 = cursor_column * self.font_width 193 | y0 = cursor_line * self.font_height 194 | x1 = x0 + self.font_width 195 | y1 = y0 + self.font_height 196 | self.canvas.coords(self.cursor_id, (x0, y0, x1, y1)) 197 | if self.max_line < cursor_line: 198 | self.max_line = cursor_line 199 | scr_height = (self.max_line + 1) * self.font_height 200 | self.canvas.config( 201 | scrollregion=(-SLOP, -SLOP, COLUMNS*self.font_width+SLOP, scr_height+SLOP)) 202 | cy = self.canvas.canvasy(0) 203 | height = self.canvas.winfo_height() 204 | y0 -= SLOP 205 | y1 += SLOP 206 | # slop makes these calculations weird and possibly incorrect 207 | #print('cursor[%s:%s] canvas[%s:%s]' % (y0, y1, cy, cy+height)) 208 | if y0 < cy: 209 | self.canvas.yview_moveto(y0/scr_height) 210 | elif y1 > cy + height: 211 | self.canvas.yview_moveto((y1 - height + SLOP*2)/scr_height) 212 | 213 | def reinit(self): 214 | "Clear everything" 215 | self.canvas.delete('all') 216 | bbox = (0, 0, self.font_width, self.font_height) 217 | self.cursor_id = self.canvas.create_rectangle(bbox) 218 | 219 | def mainloop(self, terminal): 220 | "main loop" 221 | self.terminal = terminal 222 | self.root.mainloop() 223 | 224 | 225 | class PygameFrontend: 226 | "Front-end using pygame for rendering" 227 | # pylint: disable=too-many-instance-attributes 228 | def __init__(self, target_surface=None, lines_per_page=8): 229 | pygame.init() 230 | self.font = pygame.font.SysFont('monospace', 24) 231 | self.font_width, self.font_height = self.font.size('X') 232 | self.width_pixels = COLUMNS * self.font_width 233 | if target_surface is None: 234 | pygame.display.set_caption('Terminal') 235 | dim = self.width_pixels, 22*self.font_height 236 | target_surface = pygame.display.set_mode(dim) #, pygame.RESIZABLE) 237 | target_surface.fill(background_color()) 238 | pygame.display.update() 239 | self.page_surfaces = [] 240 | self.target_surface = target_surface 241 | self.lines_per_page = lines_per_page 242 | self.char_event_num = pygame.USEREVENT+1 243 | self.terminal = None 244 | 245 | def reinit(self, lines_per_page=None): 246 | "Clears and resets all terminal state" 247 | self.page_surfaces.clear() 248 | if lines_per_page: 249 | self.lines_per_page = lines_per_page 250 | 251 | def lines_screen(self): 252 | "Returns the number of lines on the screen" 253 | return self.target_surface.get_height() // self.font_height 254 | 255 | #def alloc_line(self, line_number): 256 | # "Bookkeeping to make sure the cursor line is valid after a linefeed" 257 | # # turned out unnecessary here 258 | # page_number, page_line = divmod(line_number, self.lines_per_page) 259 | # page_surface = alloc_page(page_number) 260 | # rect1 = (0, page_line * self.font_height, self.width_pixels, self.font_height) 261 | # page_surface.fill(background_color(), rect1) 262 | 263 | def alloc_page(self, i): 264 | "Returns the i'th page surface" 265 | while len(self.page_surfaces) <= i: 266 | page_surface = pygame.Surface((self.width_pixels, self.lines_per_page*self.font_height)) 267 | page_surface.fill(background_color()) 268 | self.page_surfaces.append(page_surface) 269 | return self.page_surfaces[i] 270 | 271 | def blit_page_to_screen(self, page_number, scroll_base): 272 | "Refreshes a single page surface to the screen" 273 | line0 = page_number * self.lines_per_page 274 | line1 = (page_number + 1) * self.lines_per_page 275 | if line1 < scroll_base: 276 | return # page is off top of screen 277 | if line0 > scroll_base + self.lines_screen(): 278 | return # page is off bottom of screen 279 | dest = (0, self.font_height*(line0 - scroll_base)) 280 | area = pygame.Rect(0, 0, self.width_pixels, self.lines_per_page*self.font_height) 281 | page_surface = self.page_surfaces[page_number] 282 | #print("blit page", page_number, dest, area) 283 | self.target_surface.blit(page_surface, dest, area) 284 | 285 | def draw_cursor(self, phys_line, column): 286 | "Draws the cursor" 287 | curs = pygame.Rect( 288 | self.font_width*column, 289 | self.font_height*phys_line, 290 | self.font_width, self.font_height) 291 | pygame.draw.rect(self.target_surface, TEXT_COLOR, curs, 1) 292 | 293 | def refresh_screen(self, scroll_base, cursor_line, cursor_column): 294 | "Refreshes the screen" 295 | cursor_phys_line = cursor_line - scroll_base 296 | for i in range(len(self.page_surfaces)): 297 | self.blit_page_to_screen(i, scroll_base) 298 | self.draw_cursor(cursor_phys_line, cursor_column) 299 | pygame.display.update() 300 | sys.stdout.flush() 301 | 302 | def draw_char(self, line, column, char): 303 | "Draws a character on the page backing" 304 | text = self.font.render(char, True, TEXT_COLOR) 305 | page_number, page_line = divmod(line, self.lines_per_page) 306 | page_surface = self.alloc_page(page_number) 307 | page_surface.blit(text, (self.font_width*column, self.font_height*page_line)) 308 | 309 | def postchars(self, chars): 310 | "Post message with characters to render." 311 | pygame.event.post(pygame.event.Event(self.char_event_num, chars=chars)) 312 | 313 | def handle_key(self, event): 314 | "Handle a keyboard event" 315 | if event.unicode: 316 | self.terminal.backend.write_char(event.unicode) 317 | pygame.display.update() 318 | elif event.key == pygame.K_F5: 319 | self.terminal.backend.fast_mode = True 320 | elif event.key == pygame.K_PAGEUP: 321 | self.terminal.page_up() 322 | elif event.key == pygame.K_PAGEDOWN: 323 | self.terminal.page_down() 324 | else: 325 | pass 326 | #print(event) 327 | 328 | def mainloop(self, terminal): 329 | "Run game loop" 330 | self.terminal = terminal 331 | while True: 332 | for event in pygame.event.get(): 333 | if event.type == pygame.QUIT: 334 | pygame.quit() 335 | sys.exit() 336 | if event.type == pygame.KEYDOWN: 337 | self.handle_key(event) 338 | if event.type == pygame.KEYUP: 339 | if event.key == pygame.K_F5: 340 | self.terminal.backend.fast_mode = False 341 | if event.type == pygame.VIDEORESIZE: 342 | # Extremely finicky, but it seems to work 343 | height = event.dict['size'][1] 344 | height = height // self.font_height * self.font_height 345 | pygame.display.set_mode((self.width_pixels, height), pygame.RESIZABLE) 346 | self.target_surface.fill(background_color()) 347 | self.terminal.scroll_into_view() 348 | self.terminal.refresh_screen() 349 | if event.type == self.char_event_num: 350 | self.terminal.output_chars(event.chars) 351 | 352 | # pylint: disable=unused-argument,no-self-use,missing-docstring 353 | class DummyFrontend: 354 | "Front end that does nothing except a minimal connection to the terminal" 355 | def __init__(self, terminal=None): 356 | self.terminal = terminal 357 | 358 | def postchars(self, chars): 359 | self.terminal.output_chars(chars) 360 | 361 | def draw_char(self, line, column, char): 362 | sys.stdout.write(char) 363 | sys.stdout.flush() 364 | 365 | def lines_screen(self): 366 | return 24 367 | 368 | def refresh_screen(self, scroll_base, cursor_phys_line, cursor_column): 369 | pass 370 | 371 | def reinit(self): 372 | pass 373 | 374 | def mainloop(self, terminal): 375 | self.terminal = terminal 376 | while True: 377 | chars = sys.stdin.buffer.read1(1).decode('ascii', 'replace') 378 | if not chars: 379 | return 380 | terminal.backend.write_char(chars) 381 | 382 | 383 | class Terminal: 384 | "Class for keeping track of the terminal state." 385 | 386 | def __init__(self, frontend=None, backend=None): 387 | if backend is None: 388 | backend = LoopbackBackend() 389 | if frontend is None: 390 | frontend = DummyFrontend(self) 391 | self.line = 0 392 | self.column = 0 393 | self.scroll_base = 0 394 | self.max_line = 0 395 | self.frontend = frontend 396 | self.backend = backend 397 | self.lines = {} 398 | 399 | def reinit(self): 400 | "Discard all state" 401 | self.frontend.reinit() 402 | self.line = 0 403 | self.column = 0 404 | self.scroll_base = 0 405 | self.max_line = 0 406 | self.lines.clear() 407 | 408 | def alloc_line(self, line): 409 | try: 410 | return self.lines[line] 411 | except KeyError: 412 | return self.lines.setdefault(line, AbstractLine()) 413 | 414 | def output_char(self, char, refresh=True): 415 | "Simulates a teletype for a single character" 416 | #print("output_char", repr(char)) 417 | if char == '\n': 418 | self.line += 1 419 | elif char == '\r': 420 | self.column = 0 421 | elif char == '\t': 422 | self.column = (self.column + 7) // 8 * 8 423 | elif char == '\b': 424 | self.column -= 1 425 | elif char == '\f': 426 | self.reinit() 427 | elif char >= ' ': 428 | char = upper(char) 429 | self.alloc_line(self.line).place_char(self.column, char) 430 | self.frontend.draw_char(self.line, self.column, char) 431 | self.column += 1 432 | self.constrain_cursor() 433 | self.scroll_into_view() 434 | if refresh: 435 | self.refresh_screen() 436 | 437 | def lines_screen(self): 438 | "Returns the number of lines on the screen (from front-end)" 439 | return self.frontend.lines_screen() 440 | 441 | def refresh_screen(self): 442 | "Refreshes the screen (to front-end)" 443 | self.frontend.refresh_screen(self.scroll_base, self.line, self.column) 444 | 445 | def output_chars(self, chars, refresh=True): 446 | "Calls output_char in a loop without refreshing" 447 | for char in chars: 448 | self.output_char(char, False) 449 | if refresh: 450 | self.refresh_screen() 451 | 452 | def constrain_cursor(self): 453 | "Ensure cursor is not out of bounds" 454 | if self.line < 0: 455 | self.line = 0 456 | if self.column < 0: 457 | self.column = 0 458 | if self.column >= COLUMNS: 459 | self.column = COLUMNS-1 460 | 461 | def scroll_into_view(self, line=None): 462 | "Scroll line into view" 463 | if line is None: 464 | line = self.line 465 | if line < self.scroll_base: 466 | self.scroll_base = line 467 | if line >= self.scroll_base + self.lines_screen(): 468 | self.scroll_base = line - self.lines_screen() + 1 469 | 470 | def page_down(self): 471 | "Scrolls the page down" 472 | self.scroll_base += self.lines_screen() // 2 473 | self.constrain_scroll() 474 | self.refresh_screen() 475 | 476 | def page_up(self): 477 | "Scrolls the page up" 478 | self.scroll_base -= self.lines_screen() // 2 479 | self.constrain_scroll() 480 | self.refresh_screen() 481 | 482 | def constrain_scroll(self): 483 | "Ensures scroll is in bounds" 484 | if self.line > self.max_line: 485 | self.max_line = self.line 486 | if self.scroll_base > self.max_line - self.lines_screen() + 1: 487 | self.scroll_base = self.max_line - self.lines_screen() + 1 488 | if self.scroll_base < 0: 489 | self.scroll_base = 0 490 | 491 | class LoopbackBackend: 492 | "Just sends characters from the keyboard back to the screen" 493 | def __init__(self, postchars=lambda chars: None): 494 | self.postchars = postchars 495 | 496 | def write_char(self, char): 497 | "Echo back keyboard character" 498 | self.postchars(char) 499 | 500 | def thread_target(self): 501 | pass 502 | 503 | class ParamikoBackend: 504 | "Connects a remote host to the terminal" 505 | def __init__(self, host, username, keyfile, postchars=lambda chars: None): 506 | self.fast_mode = False 507 | self.channel = None 508 | self.postchars = postchars 509 | self.host = host 510 | self.username = username 511 | self.keyfile = keyfile 512 | 513 | def write_char(self, char): 514 | "Sends a keyboard character to the host" 515 | if self.channel is not None: 516 | self.channel.send(char.encode()) 517 | else: 518 | self.postchars(char) 519 | 520 | def thread_target(self): 521 | "Method for thread setup" 522 | ssh = paramiko.Transport((self.host, 22)) 523 | key = paramiko.RSAKey.from_private_key_file(self.keyfile) 524 | ssh.connect(username=self.username, pkey=key) 525 | self.channel = ssh.open_session() 526 | self.channel.get_pty(term='tty33') 527 | self.channel.invoke_shell() 528 | while True: 529 | if self.fast_mode: 530 | data = self.channel.recv(1024) 531 | if not data: 532 | break 533 | self.postchars(data.decode('ascii', 'replace')) 534 | else: 535 | byte = self.channel.recv(1) 536 | if not byte: 537 | break 538 | self.postchars(byte.decode('ascii', 'replace')) 539 | time.sleep(0.1) 540 | self.channel = None 541 | self.postchars("Disconnected. Local mode.\r\n") 542 | 543 | 544 | class FiledescBackend(abc.ABC): 545 | "Base classes for backends using os.read/write" 546 | def __init__(self, lecho=False, crmod=False, postchars=lambda chars: None): 547 | self.fast_mode = False 548 | self.channel = None 549 | self.postchars = postchars 550 | self.write_fd = None 551 | self.read_fd = None 552 | self.crmod = crmod 553 | self.lecho = lecho 554 | 555 | def write_char(self, char): 556 | if self.write_fd is not None: 557 | if self.crmod: 558 | char = char.replace('\r', '\n') 559 | os.write(self.write_fd, char.encode()) 560 | if self.lecho: 561 | if self.crmod: 562 | char = char.replace('\n', '\r\n') 563 | self.postchars(char) 564 | else: 565 | self.postchars(char) 566 | 567 | @abc.abstractmethod 568 | def setup(self): 569 | ... 570 | 571 | def teardown(self): 572 | if self.read_fd is not None: 573 | os.close(self.read_fd) 574 | if self.write_fd is not None: 575 | os.close(self.write_fd) 576 | self.read_fd = self.write_fd = None 577 | 578 | def thread_target(self): 579 | self.setup() 580 | while True: 581 | if self.fast_mode: 582 | data = os.read(self.read_fd, 1024) 583 | if not data: 584 | break 585 | if self.crmod: 586 | data = data.replace(b'\n', b'\r\n') 587 | self.postchars(data.decode('ascii', 'replace')) 588 | else: 589 | byte = os.read(self.read_fd, 1) 590 | if not byte: 591 | break 592 | if self.crmod: 593 | byte = byte.replace(b'\n', b'\r\n') 594 | self.postchars(byte.decode('ascii', 'replace')) 595 | time.sleep(0.1) 596 | self.teardown() 597 | self.postchars("Disconnected. Local mode.\r\n") 598 | 599 | class PipeBackend(FiledescBackend): 600 | """Backend for a subprocess running in a pipe pair. 601 | Not very useful, but cross-platform.""" 602 | def __init__(self, cmd, shell=False, **kwargs): 603 | super().__init__(**kwargs) 604 | self.cmd = cmd 605 | self.shell = shell 606 | self.proc = None 607 | 608 | def setup(self): 609 | "Starts the process and hooks up the file descriptors" 610 | self.proc = subprocess.Popen( 611 | self.cmd, shell=self.shell, 612 | stdin=subprocess.PIPE, 613 | stdout=subprocess.PIPE, 614 | stderr=subprocess.STDOUT) 615 | self.write_fd = self.proc.stdin.fileno() 616 | self.read_fd = self.proc.stdout.fileno() 617 | 618 | def teardown(self): 619 | "Closes the file descriptors" 620 | # Is there a good way to close this other than let gc take care of it? 621 | self.proc = None 622 | self.read_fd = self.write_fd = None 623 | 624 | class PtyBackend(FiledescBackend): 625 | """Backend for a subprocess running in a pipe pair. 626 | Not very useful, but cross-platform.""" 627 | def __init__(self, cmd, shell=False, **kwargs): 628 | super().__init__(**kwargs) 629 | self.cmd = cmd 630 | if type(cmd) is str: 631 | if shell: 632 | self.args = ['sh', '-c', cmd] 633 | else: 634 | self.args = shlex.split(cmd) 635 | else: 636 | self.args = cmd 637 | 638 | def setup(self): 639 | "Starts the process and hooks up the file descriptors" 640 | pid, master = pty.fork() 641 | if pid: 642 | self.write_fd = self.read_fd = master 643 | else: 644 | try: 645 | attr = termios.tcgetattr(0) 646 | attr[3] &= ~(termios.ECHOE|termios.ECHOKE) 647 | attr[3] |= termios.ECHOPRT|termios.ECHOK 648 | attr[4] = termios.B110 649 | attr[5] = termios.B110 650 | attr[6][termios.VERASE] = b'#' 651 | attr[6][termios.VKILL] = b'@' 652 | termios.tcsetattr(0, termios.TCSANOW, attr) 653 | os.environ['TERM'] = 'tty33' 654 | os.execvp(self.args[0], self.args) 655 | except Exception as ex: 656 | os.write(2, str(ex).encode('ascii', 'replace')) 657 | os.write(2, b'\r\n') 658 | os._exit(126) 659 | os._exit(126) 660 | 661 | def teardown(self): 662 | "Closes the file descriptor" 663 | os.close(self.read_fd) 664 | self.read_fd = self.write_fd = None 665 | 666 | def main(frontend, backend): 667 | "Main function" 668 | my_term = Terminal(frontend, backend) 669 | backend.postchars = frontend.postchars 670 | backend_thread = threading.Thread(target=backend.thread_target) 671 | backend_thread.start() 672 | frontend.mainloop(my_term) 673 | 674 | main(TkinterFrontend(), PtyBackend('sh')) 675 | #main(PygameFrontend(), LoopbackBackend()) 676 | #main(TkinterFrontend(), ConptyBackend('ubuntu')) 677 | #main(PygameFrontend(), PipeBackend('py -3 -i -c ""', crmod=True, lecho=True)) 678 | #main(DummyFrontend(), LoopbackBackend()) 679 | #main(DummyFrontend(), PtyBackend('sh')) 680 | #AbstractLine.unit_test('bold\rbold') 681 | #AbstractLine.unit_test('___________\runderlined') 682 | #AbstractLine.unit_test('b\bbo\bol\bld\bd') 683 | #AbstractLine.unit_test('_\bu_\bn_\bd_\be_\br_\bl_\bi_\bn_\be_\bd') 684 | #AbstractLine.unit_test('Tabs\tone\ttwo\tthree\tfour') 685 | #AbstractLine.unit_test('Spaces one two three four ') 686 | #AbstractLine.unit_test( 687 | # 'Test\tb\bbo\bol\bod\bd\t' 688 | # '_\bu_\bn_\bd_\be_\br_\bl_\bi_\bn_\be_\bd\t' 689 | # 'bold\b\b\b\bbold\t' 690 | # '__________\b\b\b\b\b\b\b\b\b\bunderlined\t' 691 | # 'both\b\b\b\b____\b\b\b\bboth\t' 692 | # 'And here is some junk to run off the right hand edge.') 693 | #AbstractLine.unit_test("Hello, world. This line has some spaces.") 694 | --------------------------------------------------------------------------------