# -*- test-case-name: twisted.conch.test.test_helper -*- # Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. """ Partial in-memory terminal emulator @author: Jp Calderone """ import re import string from zope.interface import implementer from incremental import Version from twisted.conch.insults import insults from twisted.internet import defer, protocol, reactor from twisted.logger import Logger from twisted.python import _textattributes from twisted.python.compat import iterbytes from twisted.python.deprecate import deprecated, deprecatedModuleAttribute FOREGROUND = 30 BACKGROUND = 40 BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, N_COLORS = range(9) class _FormattingState(_textattributes._FormattingStateMixin): """ Represents the formatting state/attributes of a single character. Character set, intensity, underlinedness, blinkitude, video reversal, as well as foreground and background colors made up a character's attributes. """ compareAttributes = ( "charset", "bold", "underline", "blink", "reverseVideo", "foreground", "background", "_subtracting", ) def __init__( self, charset=insults.G0, bold=False, underline=False, blink=False, reverseVideo=False, foreground=WHITE, background=BLACK, _subtracting=False, ): self.charset = charset self.bold = bold self.underline = underline self.blink = blink self.reverseVideo = reverseVideo self.foreground = foreground self.background = background self._subtracting = _subtracting @deprecated(Version("Twisted", 13, 1, 0)) def wantOne(self, **kw): """ Add a character attribute to a copy of this formatting state. @param kw: An optional attribute name and value can be provided with a keyword argument. @return: A formatting state instance with the new attribute. @see: L{DefaultFormattingState._withAttribute}. """ k, v = kw.popitem() return self._withAttribute(k, v) def toVT102(self): # Spit out a vt102 control sequence that will set up # all the attributes set here. Except charset. attrs = [] if self._subtracting: attrs.append(0) if self.bold: attrs.append(insults.BOLD) if self.underline: attrs.append(insults.UNDERLINE) if self.blink: attrs.append(insults.BLINK) if self.reverseVideo: attrs.append(insults.REVERSE_VIDEO) if self.foreground != WHITE: attrs.append(FOREGROUND + self.foreground) if self.background != BLACK: attrs.append(BACKGROUND + self.background) if attrs: return "\x1b[" + ";".join(map(str, attrs)) + "m" return "" CharacterAttribute = _FormattingState deprecatedModuleAttribute( Version("Twisted", 13, 1, 0), "Use twisted.conch.insults.text.assembleFormattedText instead.", "twisted.conch.insults.helper", "CharacterAttribute", ) # XXX - need to support scroll regions and scroll history @implementer(insults.ITerminalTransport) class TerminalBuffer(protocol.Protocol): """ An in-memory terminal emulator. """ for keyID in ( b"UP_ARROW", b"DOWN_ARROW", b"RIGHT_ARROW", b"LEFT_ARROW", b"HOME", b"INSERT", b"DELETE", b"END", b"PGUP", b"PGDN", b"F1", b"F2", b"F3", b"F4", b"F5", b"F6", b"F7", b"F8", b"F9", b"F10", b"F11", b"F12", ): execBytes = keyID + b" = object()" execStr = execBytes.decode("ascii") exec(execStr) TAB = b"\t" BACKSPACE = b"\x7f" width = 80 height = 24 fill = b" " void = object() _log = Logger() def getCharacter(self, x, y): return self.lines[y][x] def connectionMade(self): self.reset() def write(self, data): """ Add the given printable bytes to the terminal. Line feeds in L{bytes} will be replaced with carriage return / line feed pairs. """ for b in iterbytes(data.replace(b"\n", b"\r\n")): self.insertAtCursor(b) def _currentFormattingState(self): return _FormattingState(self.activeCharset, **self.graphicRendition) def insertAtCursor(self, b): """ Add one byte to the terminal at the cursor and make consequent state updates. If b is a carriage return, move the cursor to the beginning of the current row. If b is a line feed, move the cursor to the next row or scroll down if the cursor is already in the last row. Otherwise, if b is printable, put it at the cursor position (inserting or overwriting as dictated by the current mode) and move the cursor. """ if b == b"\r": self.x = 0 elif b == b"\n": self._scrollDown() elif b in string.printable.encode("ascii"): if self.x >= self.width: self.nextLine() ch = (b, self._currentFormattingState()) if self.modes.get(insults.modes.IRM): self.lines[self.y][self.x : self.x] = [ch] self.lines[self.y].pop() else: self.lines[self.y][self.x] = ch self.x += 1 def _emptyLine(self, width): return [(self.void, self._currentFormattingState()) for i in range(width)] def _scrollDown(self): self.y += 1 if self.y >= self.height: self.y -= 1 del self.lines[0] self.lines.append(self._emptyLine(self.width)) def _scrollUp(self): self.y -= 1 if self.y < 0: self.y = 0 del self.lines[-1] self.lines.insert(0, self._emptyLine(self.width)) def cursorUp(self, n=1): self.y = max(0, self.y - n) def cursorDown(self, n=1): self.y = min(self.height - 1, self.y + n) def cursorBackward(self, n=1): self.x = max(0, self.x - n) def cursorForward(self, n=1): self.x = min(self.width, self.x + n) def cursorPosition(self, column, line): self.x = column self.y = line def cursorHome(self): self.x = self.home.x self.y = self.home.y def index(self): self._scrollDown() def reverseIndex(self): self._scrollUp() def nextLine(self): """ Update the cursor position attributes and scroll down if appropriate. """ self.x = 0 self._scrollDown() def saveCursor(self): self._savedCursor = (self.x, self.y) def restoreCursor(self): self.x, self.y = self._savedCursor del self._savedCursor def setModes(self, modes): for m in modes: self.modes[m] = True def resetModes(self, modes): for m in modes: try: del self.modes[m] except KeyError: pass def setPrivateModes(self, modes): """ Enable the given modes. Track which modes have been enabled so that the implementations of other L{insults.ITerminalTransport} methods can be properly implemented to respect these settings. @see: L{resetPrivateModes} @see: L{insults.ITerminalTransport.setPrivateModes} """ for m in modes: self.privateModes[m] = True def resetPrivateModes(self, modes): """ Disable the given modes. @see: L{setPrivateModes} @see: L{insults.ITerminalTransport.resetPrivateModes} """ for m in modes: try: del self.privateModes[m] except KeyError: pass def applicationKeypadMode(self): self.keypadMode = "app" def numericKeypadMode(self): self.keypadMode = "num" def selectCharacterSet(self, charSet, which): self.charsets[which] = charSet def shiftIn(self): self.activeCharset = insults.G0 def shiftOut(self): self.activeCharset = insults.G1 def singleShift2(self): oldActiveCharset = self.activeCharset self.activeCharset = insults.G2 f = self.insertAtCursor def insertAtCursor(b): f(b) del self.insertAtCursor self.activeCharset = oldActiveCharset self.insertAtCursor = insertAtCursor def singleShift3(self): oldActiveCharset = self.activeCharset self.activeCharset = insults.G3 f = self.insertAtCursor def insertAtCursor(b): f(b) del self.insertAtCursor self.activeCharset = oldActiveCharset self.insertAtCursor = insertAtCursor def selectGraphicRendition(self, *attributes): for a in attributes: if a == insults.NORMAL: self.graphicRendition = { "bold": False, "underline": False, "blink": False, "reverseVideo": False, "foreground": WHITE, "background": BLACK, } elif a == insults.BOLD: self.graphicRendition["bold"] = True elif a == insults.UNDERLINE: self.graphicRendition["underline"] = True elif a == insults.BLINK: self.graphicRendition["blink"] = True elif a == insults.REVERSE_VIDEO: self.graphicRendition["reverseVideo"] = True else: try: v = int(a) except ValueError: self._log.error( "Unknown graphic rendition attribute: {attr!r}", attr=a ) else: if FOREGROUND <= v <= FOREGROUND + N_COLORS: self.graphicRendition["foreground"] = v - FOREGROUND elif BACKGROUND <= v <= BACKGROUND + N_COLORS: self.graphicRendition["background"] = v - BACKGROUND else: self._log.error( "Unknown graphic rendition attribute: {attr!r}", attr=a ) def eraseLine(self): self.lines[self.y] = self._emptyLine(self.width) def eraseToLineEnd(self): width = self.width - self.x self.lines[self.y][self.x :] = self._emptyLine(width) def eraseToLineBeginning(self): self.lines[self.y][: self.x + 1] = self._emptyLine(self.x + 1) def eraseDisplay(self): self.lines = [self._emptyLine(self.width) for i in range(self.height)] def eraseToDisplayEnd(self): self.eraseToLineEnd() height = self.height - self.y - 1 self.lines[self.y + 1 :] = [self._emptyLine(self.width) for i in range(height)] def eraseToDisplayBeginning(self): self.eraseToLineBeginning() self.lines[: self.y] = [self._emptyLine(self.width) for i in range(self.y)] def deleteCharacter(self, n=1): del self.lines[self.y][self.x : self.x + n] self.lines[self.y].extend(self._emptyLine(min(self.width - self.x, n))) def insertLine(self, n=1): self.lines[self.y : self.y] = [self._emptyLine(self.width) for i in range(n)] del self.lines[self.height :] def deleteLine(self, n=1): del self.lines[self.y : self.y + n] self.lines.extend([self._emptyLine(self.width) for i in range(n)]) def reportCursorPosition(self): return (self.x, self.y) def reset(self): self.home = insults.Vector(0, 0) self.x = self.y = 0 self.modes = {} self.privateModes = {} self.setPrivateModes( [insults.privateModes.AUTO_WRAP, insults.privateModes.CURSOR_MODE] ) self.numericKeypad = "app" self.activeCharset = insults.G0 self.graphicRendition = { "bold": False, "underline": False, "blink": False, "reverseVideo": False, "foreground": WHITE, "background": BLACK, } self.charsets = { insults.G0: insults.CS_US, insults.G1: insults.CS_US, insults.G2: insults.CS_ALTERNATE, insults.G3: insults.CS_ALTERNATE_SPECIAL, } self.eraseDisplay() def unhandledControlSequence(self, buf): print("Could not handle", repr(buf)) def __bytes__(self): lines = [] for L in self.lines: buf = [] length = 0 for (ch, attr) in L: if ch is not self.void: buf.append(ch) length = len(buf) else: buf.append(self.fill) lines.append(b"".join(buf[:length])) return b"\n".join(lines) def getHost(self): # ITransport.getHost raise NotImplementedError("Unimplemented: TerminalBuffer.getHost") def getPeer(self): # ITransport.getPeer raise NotImplementedError("Unimplemented: TerminalBuffer.getPeer") def loseConnection(self): # ITransport.loseConnection raise NotImplementedError("Unimplemented: TerminalBuffer.loseConnection") def writeSequence(self, data): # ITransport.writeSequence raise NotImplementedError("Unimplemented: TerminalBuffer.writeSequence") def horizontalTabulationSet(self): # ITerminalTransport.horizontalTabulationSet raise NotImplementedError( "Unimplemented: TerminalBuffer.horizontalTabulationSet" ) def tabulationClear(self): # TerminalTransport.tabulationClear raise NotImplementedError("Unimplemented: TerminalBuffer.tabulationClear") def tabulationClearAll(self): # TerminalTransport.tabulationClearAll raise NotImplementedError("Unimplemented: TerminalBuffer.tabulationClearAll") def doubleHeightLine(self, top=True): # ITerminalTransport.doubleHeightLine raise NotImplementedError("Unimplemented: TerminalBuffer.doubleHeightLine") def singleWidthLine(self): # ITerminalTransport.singleWidthLine raise NotImplementedError("Unimplemented: TerminalBuffer.singleWidthLine") def doubleWidthLine(self): # ITerminalTransport.doubleWidthLine raise NotImplementedError("Unimplemented: TerminalBuffer.doubleWidthLine") class ExpectationTimeout(Exception): pass class ExpectableBuffer(TerminalBuffer): _mark = 0 def connectionMade(self): TerminalBuffer.connectionMade(self) self._expecting = [] def write(self, data): TerminalBuffer.write(self, data) self._checkExpected() def cursorHome(self): TerminalBuffer.cursorHome(self) self._mark = 0 def _timeoutExpected(self, d): d.errback(ExpectationTimeout()) self._checkExpected() def _checkExpected(self): s = self.__bytes__()[self._mark :] while self._expecting: expr, timer, deferred = self._expecting[0] if timer and not timer.active(): del self._expecting[0] continue for match in expr.finditer(s): if timer: timer.cancel() del self._expecting[0] self._mark += match.end() s = s[match.end() :] deferred.callback(match) break else: return def expect(self, expression, timeout=None, scheduler=reactor): d = defer.Deferred() timer = None if timeout: timer = scheduler.callLater(timeout, self._timeoutExpected, d) self._expecting.append((re.compile(expression), timer, d)) self._checkExpected() return d __all__ = ["CharacterAttribute", "TerminalBuffer", "ExpectableBuffer"]