diff --git a/pyte/AUTHORS b/pyte/AUTHORS
new file mode 100644
index 000000000..0840f4936
--- /dev/null
+++ b/pyte/AUTHORS
@@ -0,0 +1,14 @@
+Authors
+=======
+
+- George Shuklin
+- Sergei Lebedev
+
+Contributors
+------------
+
+- Alexey Shamrin
+- Steve Cohen
+- Jonathan Slenders
+- David O'Shea
+- Andreas Stührk
diff --git a/pyte/LICENSE b/pyte/LICENSE
new file mode 100644
index 000000000..65c5ca88a
--- /dev/null
+++ b/pyte/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/pyte/__init__.py b/pyte/__init__.py
new file mode 100644
index 000000000..eae245ae9
--- /dev/null
+++ b/pyte/__init__.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte
+ ~~~~
+
+ `pyte` implements a mix of VT100, VT220 and VT520 specification,
+ and aims to support most of the `TERM=linux` functionality.
+
+ Two classes: :class:`~pyte.streams.Stream`, which parses the
+ command stream and dispatches events for commands, and
+ :class:`~pyte.screens.Screen` which, when used with a stream
+ maintains a buffer of strings representing the screen of a
+ terminal.
+
+ .. warning:: From ``xterm/main.c`` "If you think you know what all
+ of this code is doing, you are probably very mistaken.
+ There be serious and nasty dragons here" -- nothing
+ has changed.
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+from __future__ import absolute_import
+
+__all__ = ("Screen", "DiffScreen", "HistoryScreen",
+ "Stream", "ByteStream", "DebugStream")
+
+import io
+
+from .screens import Screen, DiffScreen, HistoryScreen
+from .streams import Stream, ByteStream, DebugStream
+
+
+if __debug__:
+ from .compat import str
+
+ def dis(chars):
+ """A :func:`dis.dis` for terminals.
+
+ >>> dis(b"\x07") # doctest: +NORMALIZE_WHITESPACE
+ BELL
+ >>> dis(b"\x1b[20m") # doctest: +NORMALIZE_WHITESPACE
+ SELECT_GRAPHIC_RENDITION 20
+ """
+ if isinstance(chars, str):
+ chars = chars.encode("utf-8")
+
+ with io.StringIO() as buf:
+ DebugStream(to=buf).feed(chars)
+ print(buf.getvalue())
diff --git a/pyte/__main__.py b/pyte/__main__.py
new file mode 100644
index 000000000..c87805ac0
--- /dev/null
+++ b/pyte/__main__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte
+ ~~~~
+
+ Command-line tool for "disassembling" escape and CSI sequences::
+
+ $ echo -e "\e[Jfoo" | python -m pyte
+ ERASE_IN_DISPLAY 0
+ DRAW f
+ DRAW o
+ DRAW o
+ LINEFEED
+
+ $ python -m pyte foo
+ DRAW f
+ DRAW o
+ DRAW o
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+if __name__ == "__main__":
+ import sys
+ import pyte
+
+ if len(sys.argv) == 1:
+ pyte.dis(sys.stdin.read())
+ else:
+ pyte.dis("".join(sys.argv[1:]))
diff --git a/pyte/charsets.py b/pyte/charsets.py
new file mode 100644
index 000000000..4957fe2e4
--- /dev/null
+++ b/pyte/charsets.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.charsets
+ ~~~~~~~~~~~~~
+
+ This module defines ``G0`` and ``G1`` charset mappings the same way
+ they are defined for linux terminal, see
+ ``linux/drivers/tty/consolemap.c`` @ http://git.kernel.org
+
+ .. note:: ``VT100_MAP`` and ``IBMPC_MAP`` were taken unchanged
+ from linux kernel source and therefore are licensed
+ under **GPL**.
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+from __future__ import absolute_import, unicode_literals
+
+from .compat import chr, map
+
+
+#: Latin1.
+LAT1_MAP = "".join(map(chr, range(256)))
+
+#: VT100 graphic character set.
+VT100_MAP = "".join(chr(c) for c in [
+ 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+ 0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x000e, 0x000f,
+ 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+ 0x0018, 0x0019, 0x001a, 0x001b, 0x001c, 0x001d, 0x001e, 0x001f,
+ 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+ 0x0028, 0x0029, 0x002a, 0x2192, 0x2190, 0x2191, 0x2193, 0x002f,
+ 0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+ 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
+ 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+ 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
+ 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+ 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x00a0,
+ 0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1,
+ 0x2591, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba,
+ 0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c,
+ 0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x007f,
+ 0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
+ 0x0088, 0x0089, 0x008a, 0x008b, 0x008c, 0x008d, 0x008e, 0x008f,
+ 0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
+ 0x0098, 0x0099, 0x009a, 0x009b, 0x009c, 0x009d, 0x009e, 0x009f,
+ 0x00a0, 0x00a1, 0x00a2, 0x00a3, 0x00a4, 0x00a5, 0x00a6, 0x00a7,
+ 0x00a8, 0x00a9, 0x00aa, 0x00ab, 0x00ac, 0x00ad, 0x00ae, 0x00af,
+ 0x00b0, 0x00b1, 0x00b2, 0x00b3, 0x00b4, 0x00b5, 0x00b6, 0x00b7,
+ 0x00b8, 0x00b9, 0x00ba, 0x00bb, 0x00bc, 0x00bd, 0x00be, 0x00bf,
+ 0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c6, 0x00c7,
+ 0x00c8, 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf,
+ 0x00d0, 0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d7,
+ 0x00d8, 0x00d9, 0x00da, 0x00db, 0x00dc, 0x00dd, 0x00de, 0x00df,
+ 0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, 0x00e5, 0x00e6, 0x00e7,
+ 0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00ed, 0x00ee, 0x00ef,
+ 0x00f0, 0x00f1, 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00f7,
+ 0x00f8, 0x00f9, 0x00fa, 0x00fb, 0x00fc, 0x00fd, 0x00fe, 0x00ff
+])
+
+#: IBM Codepage 437.
+IBMPC_MAP = "".join(chr(c) for c in [
+ 0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
+ 0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
+ 0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
+ 0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
+ 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+ 0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
+ 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+ 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
+ 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+ 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
+ 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+ 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
+ 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+ 0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x006f,
+ 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+ 0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
+ 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
+ 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
+ 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
+ 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
+ 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
+ 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
+ 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
+ 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
+ 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
+ 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
+ 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
+ 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
+ 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
+ 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
+ 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
+ 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
+])
+
+
+#: VAX42 character set.
+VAX42_MAP = "".join(chr(c) for c in [
+ 0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
+ 0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
+ 0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
+ 0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
+ 0x0020, 0x043b, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+ 0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
+ 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+ 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x0435,
+ 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+ 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
+ 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+ 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
+ 0x0060, 0x0441, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+ 0x0435, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x043a,
+ 0x0070, 0x0071, 0x0442, 0x0073, 0x043b, 0x0435, 0x0076, 0x0077,
+ 0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
+ 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
+ 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
+ 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
+ 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
+ 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
+ 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
+ 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
+ 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
+ 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
+ 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
+ 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
+ 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
+ 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
+ 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
+ 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
+ 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
+])
+
+
+MAPS = {
+ b"B": LAT1_MAP,
+ b"0": VT100_MAP,
+ b"U": IBMPC_MAP,
+ b"V": VAX42_MAP
+}
diff --git a/pyte/compat.py b/pyte/compat.py
new file mode 100644
index 000000000..cc60c193d
--- /dev/null
+++ b/pyte/compat.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.compat
+ ~~~~~~~~~~~
+
+ Python version specific compatibility fixes.
+
+ :copyright: (c) 2015-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+import sys
+
+if sys.version_info[0] == 2:
+ from future_builtins import map
+
+ range = xrange
+ str = unicode
+ chr = unichr
+
+ from functools import partial
+ iter_bytes = partial(map, ord)
+else:
+ from builtins import map, range, str, chr
+ iter_bytes = iter
diff --git a/pyte/control.py b/pyte/control.py
new file mode 100644
index 000000000..f2a774933
--- /dev/null
+++ b/pyte/control.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.control
+ ~~~~~~~~~~~~
+
+ This module defines simple control sequences, recognized by
+ :class:`~pyte.streams.Stream`, the set of codes here is for
+ ``TERM=linux`` which is a superset of VT102.
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+#: *Space*: Not suprisingly -- ``" "``.
+SP = b" "
+
+#: *Null*: Does nothing.
+NUL = b"\x00"
+
+#: *Bell*: Beeps.
+BEL = b"\x07"
+
+#: *Backspace*: Backspace one column, but not past the begining of the
+#: line.
+BS = b"\x08"
+
+#: *Horizontal tab*: Move cursor to the next tab stop, or to the end
+#: of the line if there is no earlier tab stop.
+HT = b"\x09"
+
+#: *Linefeed*: Give a line feed, and, if :data:`pyte.modes.LNM` (new
+#: line mode) is set also a carriage return.
+LF = b"\n"
+#: *Vertical tab*: Same as :data:`LF`.
+VT = b"\x0b"
+#: *Form feed*: Same as :data:`LF`.
+FF = b"\x0c"
+
+#: *Carriage return*: Move cursor to left margin on current line.
+CR = b"\r"
+
+#: *Shift out*: Activate G1 character set.
+SO = b"\x0e"
+
+#: *Shift in*: Activate G0 character set.
+SI = b"\x0f"
+
+#: *Cancel*: Interrupt escape sequence. If received during an escape or
+#: control sequence, cancels the sequence and displays substitution
+#: character.
+CAN = b"\x18"
+#: *Substitute*: Same as :data:`CAN`.
+SUB = b"\x1a"
+
+#: *Escape*: Starts an escape sequence.
+ESC = b"\x1b"
+
+#: *Delete*: Is ignored.
+DEL = b"\x7f"
+
+#: *Control sequence introducer*: An equivalent for ``ESC [``.
+CSI = b"\x9b"
+
+#: *String terminator*.
+ST = b"\x9c"
+
+#: *Operating system command*.
+OSC = b"\x9d"
diff --git a/pyte/escape.py b/pyte/escape.py
new file mode 100644
index 000000000..7c7908f26
--- /dev/null
+++ b/pyte/escape.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.escape
+ ~~~~~~~~~~~
+
+ This module defines both CSI and non-CSI escape sequences, recognized
+ by :class:`~pyte.streams.Stream` and subclasses.
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+#: *Reset*.
+RIS = b"c"
+
+#: *Index*: Move cursor down one line in same column. If the cursor is
+#: at the bottom margin, the screen performs a scroll-up.
+IND = b"D"
+
+#: *Next line*: Same as :data:`pyte.control.LF`.
+NEL = b"E"
+
+#: Tabulation set: Set a horizontal tab stop at cursor position.
+HTS = b"H"
+
+#: *Reverse index*: Move cursor up one line in same column. If the
+#: cursor is at the top margin, the screen performs a scroll-down.
+RI = b"M"
+
+#: Save cursor: Save cursor position, character attribute (graphic
+#: rendition), character set, and origin mode selection (see
+#: :data:`DECRC`).
+DECSC = b"7"
+
+#: *Restore cursor*: Restore previously saved cursor position, character
+#: attribute (graphic rendition), character set, and origin mode
+#: selection. If none were saved, move cursor to home position.
+DECRC = b"8"
+
+# "Sharp" escape sequences.
+# -------------------------
+
+#: *Alignment display*: Fill screen with uppercase E's for testing
+#: screen focus and alignment.
+DECALN = b"8"
+
+
+# ECMA-48 CSI sequences.
+# ---------------------
+
+#: *Insert character*: Insert the indicated # of blank characters.
+ICH = b"@"
+
+#: *Cursor up*: Move cursor up the indicated # of lines in same column.
+#: Cursor stops at top margin.
+CUU = b"A"
+
+#: *Cursor down*: Move cursor down the indicated # of lines in same
+#: column. Cursor stops at bottom margin.
+CUD = b"B"
+
+#: *Cursor forward*: Move cursor right the indicated # of columns.
+#: Cursor stops at right margin.
+CUF = b"C"
+
+#: *Cursor back*: Move cursor left the indicated # of columns. Cursor
+#: stops at left margin.
+CUB = b"D"
+
+#: *Cursor next line*: Move cursor down the indicated # of lines to
+#: column 1.
+CNL = b"E"
+
+#: *Cursor previous line*: Move cursor up the indicated # of lines to
+#: column 1.
+CPL = b"F"
+
+#: *Cursor horizontal align*: Move cursor to the indicated column in
+#: current line.
+CHA = b"G"
+
+#: *Cursor position*: Move cursor to the indicated line, column (origin
+#: at ``1, 1``).
+CUP = b"H"
+
+#: *Erase data* (default: from cursor to end of line).
+ED = b"J"
+
+#: *Erase in line* (default: from cursor to end of line).
+EL = b"K"
+
+#: *Insert line*: Insert the indicated # of blank lines, starting from
+#: the current line. Lines displayed below cursor move down. Lines moved
+#: past the bottom margin are lost.
+IL = b"L"
+
+#: *Delete line*: Delete the indicated # of lines, starting from the
+#: current line. As lines are deleted, lines displayed below cursor
+#: move up. Lines added to bottom of screen have spaces with same
+#: character attributes as last line move up.
+DL = b"M"
+
+#: *Delete character*: Delete the indicated # of characters on the
+#: current line. When character is deleted, all characters to the right
+#: of cursor move left.
+DCH = b"P"
+
+#: *Erase character*: Erase the indicated # of characters on the
+#: current line.
+ECH = b"X"
+
+#: *Horizontal position relative*: Same as :data:`CUF`.
+HPR = b"a"
+
+#: *Device Attributes*.
+DA = b"c"
+
+#: *Vertical position adjust*: Move cursor to the indicated line,
+#: current column.
+VPA = b"d"
+
+#: *Vertical position relative*: Same as :data:`CUD`.
+VPR = b"e"
+
+#: *Horizontal / Vertical position*: Same as :data:`CUP`.
+HVP = b"f"
+
+#: *Tabulation clear*: Clears a horizontal tab stop at cursor position.
+TBC = b"g"
+
+#: *Set mode*.
+SM = b"h"
+
+#: *Reset mode*.
+RM = b"l"
+
+#: *Select graphics rendition*: The terminal can display the following
+#: character attributes that change the character display without
+#: changing the character (see :mod:`pyte.graphics`).
+SGR = b"m"
+
+#: *Device status report*.
+DSR = b"n"
+
+#: *Select top and bottom margins*: Selects margins, defining the
+#: scrolling region; parameters are top and bottom line. If called
+#: without any arguments, whole screen is used.
+DECSTBM = b"r"
+
+#: *Horizontal position adjust*: Same as :data:`CHA`.
+HPA = b"'"
diff --git a/pyte/graphics.py b/pyte/graphics.py
new file mode 100644
index 000000000..80f85c1f4
--- /dev/null
+++ b/pyte/graphics.py
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.graphics
+ ~~~~~~~~~~~~~
+
+ This module defines graphic-related constants, mostly taken from
+ :manpage:`console_codes(4)` and
+ http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html.
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+from __future__ import unicode_literals
+
+#: A mapping of ANSI text style codes to style names, "+" means the:
+#: attribute is set, "-" -- reset; example:
+#:
+#: >>> text[1]
+#: '+bold'
+#: >>> text[9]
+#: '+strikethrough'
+TEXT = {
+ 1: "+bold" ,
+ 3: "+italics",
+ 4: "+underscore",
+ 7: "+reverse",
+ 9: "+strikethrough",
+ 22: "-bold",
+ 23: "-italics",
+ 24: "-underscore",
+ 27: "-reverse",
+ 29: "-strikethrough",
+}
+
+#: A mapping of ANSI foreground color codes to color names.
+#:
+#: >>> FG_ANSI[30]
+#: 'black'
+#: >>> FG_ANSI[38]
+#: 'default'
+FG_ANSI = {
+ 30: "black",
+ 31: "red",
+ 32: "green",
+ 33: "brown",
+ 34: "blue",
+ 35: "magenta",
+ 36: "cyan",
+ 37: "white",
+ 39: "default" # white.
+}
+
+#: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility.
+FG = FG_ANSI
+
+#: A mapping of non-standard ``aixterm`` foreground color codes to
+#: color names. These are high intensity colors and thus should be
+#: complemented by ``+bold``.
+FG_AIXTERM = {
+ 90: "black",
+ 91: "red",
+ 92: "green",
+ 93: "brown",
+ 94: "blue",
+ 95: "magenta",
+ 96: "cyan",
+ 97: "white"
+}
+
+#: A mapping of ANSI background color codes to color names.
+#:
+#: >>> BG_ANSI[40]
+#: 'black'
+#: >>> BG_ANSI[48]
+#: 'default'
+BG_ANSI = {
+ 40: "black",
+ 41: "red",
+ 42: "green",
+ 43: "brown",
+ 44: "blue",
+ 45: "magenta",
+ 46: "cyan",
+ 47: "white",
+ 49: "default" # black.
+}
+
+#: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility.
+BG = BG_ANSI
+
+#: A mapping of non-standard ``aixterm`` background color codes to
+#: color names. These are high intensity colors and thus should be
+#: complemented by ``+bold``.
+BG_AIXTERM = {
+ 100: "black",
+ 101: "red",
+ 102: "green",
+ 103: "brown",
+ 104: "blue",
+ 105: "magenta",
+ 106: "cyan",
+ 107: "white"
+}
+
+#: SGR code for foreground in 256 or True color mode.
+FG_256 = 38
+
+#: SGR code for background in 256 or True color mode.
+BG_256 = 48
+
+#: A table of 256 foreground or background colors.
+# The following code is part of the Pygments project (BSD licensed).
+FG_BG_256 = [
+ (0x00, 0x00, 0x00), # 0
+ (0xcd, 0x00, 0x00), # 1
+ (0x00, 0xcd, 0x00), # 2
+ (0xcd, 0xcd, 0x00), # 3
+ (0x00, 0x00, 0xee), # 4
+ (0xcd, 0x00, 0xcd), # 5
+ (0x00, 0xcd, 0xcd), # 6
+ (0xe5, 0xe5, 0xe5), # 7
+ (0x7f, 0x7f, 0x7f), # 8
+ (0xff, 0x00, 0x00), # 9
+ (0x00, 0xff, 0x00), # 10
+ (0xff, 0xff, 0x00), # 11
+ (0x5c, 0x5c, 0xff), # 12
+ (0xff, 0x00, 0xff), # 13
+ (0x00, 0xff, 0xff), # 14
+ (0xff, 0xff, 0xff), # 15
+]
+
+# colors 16..232: the 6x6x6 color cube
+valuerange = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff)
+
+for i in range(217):
+ r = valuerange[(i // 36) % 6]
+ g = valuerange[(i // 6) % 6]
+ b = valuerange[i % 6]
+ FG_BG_256.append((r, g, b))
+
+# colors 233..253: grayscale
+for i in range(1, 22):
+ v = 8 + i * 10
+ FG_BG_256.append((v, v, v))
+
+FG_BG_256 = ["{0:02x}{1:02x}{2:02x}".format(r, g, b) for r, g, b in FG_BG_256]
diff --git a/pyte/modes.py b/pyte/modes.py
new file mode 100644
index 000000000..5f7386cab
--- /dev/null
+++ b/pyte/modes.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.modes
+ ~~~~~~~~~~
+
+ This module defines terminal mode switches, used by
+ :class:`~pyte.screens.Screen`. There're two types of terminal modes:
+
+ * `non-private` which should be set with ``ESC [ N h``, where ``N``
+ is an integer, representing mode being set; and
+ * `private` which should be set with ``ESC [ ? N h``.
+
+ The latter are shifted 5 times to the right, to be easily
+ distinguishable from the former ones; for example `Origin Mode`
+ -- :data:`DECOM` is ``192`` not ``6``.
+
+ >>> DECOM
+ 192
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+#: *Line Feed/New Line Mode*: When enabled, causes a received
+#: :data:`~pyte.control.LF`, :data:`pyte.control.FF`, or
+#: :data:`~pyte.control.VT` to move the cursor to the first column of
+#: the next line.
+LNM = 20
+
+#: *Insert/Replace Mode*: When enabled, new display characters move
+#: old display characters to the right. Characters moved past the
+#: right margin are lost. Otherwise, new display characters replace
+#: old display characters at the cursor position.
+IRM = 4
+
+
+# Private modes.
+# ..............
+
+#: *Text Cursor Enable Mode*: determines if the text cursor is
+#: visible.
+DECTCEM = 25 << 5
+
+#: *Screen Mode*: toggles screen-wide reverse-video mode.
+DECSCNM = 5 << 5
+
+#: *Origin Mode*: allows cursor addressing relative to a user-defined
+#: origin. This mode resets when the terminal is powered up or reset.
+#: It does not affect the erase in display (ED) function.
+DECOM = 6 << 5
+
+#: *Auto Wrap Mode*: selects where received graphic characters appear
+#: when the cursor is at the right margin.
+DECAWM = 7 << 5
+
+#: *Column Mode*: selects the number of columns per line (80 or 132)
+#: on the screen.
+DECCOLM = 3 << 5
diff --git a/pyte/screens.py b/pyte/screens.py
new file mode 100644
index 000000000..2491d0f48
--- /dev/null
+++ b/pyte/screens.py
@@ -0,0 +1,1273 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.screens
+ ~~~~~~~~~~~~
+
+ This module provides classes for terminal screens, currently
+ it contains three screens with different features:
+
+ * :class:`~pyte.screens.Screen` -- base screen implementation,
+ which handles all the core escape sequences, recognized by
+ :class:`~pyte.streams.Stream`.
+ * If you need a screen to keep track of the changed lines
+ (which you probably do need) -- use
+ :class:`~pyte.screens.DiffScreen`.
+ * If you also want a screen to collect history and allow
+ pagination -- :class:`pyte.screen.HistoryScreen` is here
+ for ya ;)
+
+ .. note:: It would be nice to split those features into mixin
+ classes, rather than subclasses, but it's not obvious
+ how to do -- feel free to submit a pull request.
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+from __future__ import absolute_import, unicode_literals, division
+
+import codecs
+import copy
+import math
+import unicodedata
+from collections import deque, namedtuple
+from itertools import islice, repeat
+
+from wcwidth import wcwidth
+
+from . import (
+ charsets as cs,
+ control as ctrl,
+ graphics as g,
+ modes as mo
+)
+from .compat import iter_bytes, map, range
+from .streams import Stream
+
+
+def take(n, iterable):
+ """Returns first n items of the iterable as a list."""
+ return list(islice(iterable, n))
+
+
+#: A container for screen's scroll margins.
+Margins = namedtuple("Margins", "top bottom")
+
+#: A container for savepoint, created on :data:`~pyte.escape.DECSC`.
+Savepoint = namedtuple("Savepoint", [
+ "cursor",
+ "g0_charset",
+ "g1_charset",
+ "charset",
+ "use_utf8",
+ "origin",
+ "wrap"
+])
+
+#: A container for a single character, field names are *hopefully*
+#: self-explanatory.
+_Char = namedtuple("_Char", [
+ "data",
+ "fg",
+ "bg",
+ "bold",
+ "italics",
+ "underscore",
+ "strikethrough",
+ "reverse",
+])
+
+
+class Char(_Char):
+ """A wrapper around :class:`_Char`, providing some useful defaults
+ for most of the attributes.
+ """
+ __slots__ = ()
+
+ def __new__(cls, data, fg="default", bg="default", bold=False,
+ italics=False, underscore=False, reverse=False,
+ strikethrough=False):
+ return super(Char, cls).__new__(cls, data, fg, bg, bold, italics,
+ underscore, strikethrough, reverse)
+
+
+class Cursor(object):
+ """Screen cursor.
+
+ :param int x: 0-based horizontal cursor position.
+ :param int y: 0-based vertical cursor position.
+ :param pyte.screens.Char attrs: cursor attributes (see
+ :meth:`~pyte.screens.Screen.select_graphic_rendition`
+ for details).
+ """
+ __slots__ = ("x", "y", "attrs", "hidden")
+
+ def __init__(self, x, y, attrs=Char(" ")):
+ self.x = x
+ self.y = y
+ self.attrs = attrs
+ self.hidden = False
+
+
+class Screen(object):
+ """
+ A screen is an in-memory matrix of characters that represents the
+ screen display of the terminal. It can be instantiated on it's own
+ and given explicit commands, or it can be attached to a stream and
+ will respond to events.
+
+ .. attribute:: buffer
+
+ A ``lines x columns`` :class:`~pyte.screens.Char` matrix.
+
+ .. attribute:: cursor
+
+ Reference to the :class:`~pyte.screens.Cursor` object, holding
+ cursor position and attributes.
+
+ .. attribute:: margins
+
+ Top and bottom screen margins, defining the scrolling region;
+ the actual values are top and bottom line.
+
+ .. attribute:: charset
+
+ Current charset number; can be either ``0`` or ``1`` for `G0`
+ and `G1` respectively, note that `G0` is activated by default.
+
+ .. attribute:: use_utf8
+
+ Assume the input to :meth:`~pyte.screens.Screen.draw` is encoded
+ using UTF-8. Defaults to ``True``.
+
+ .. note::
+
+ According to ``ECMA-48`` standard, **lines and columns are
+ 1-indexed**, so, for instance ``ESC [ 10;10 f`` really means
+ -- move cursor to position (9, 9) in the display matrix.
+
+ .. versionchanged:: 0.4.7
+ .. warning::
+
+ :data:`~pyte.modes.LNM` is reset by default, to match VT220
+ specification.
+
+ .. versionchanged:: 0.4.8
+ .. warning::
+
+ If `DECAWM` mode is set than a cursor will be wrapped to the
+ **beginning** of the next line, which is the behaviour described
+ in ``man console_codes``.
+
+ .. seealso::
+
+ `Standard ECMA-48, Section 6.1.1 \
+ `_
+ for a description of the presentational component, implemented
+ by ``Screen``.
+ """
+ #: A plain empty character with default foreground and background
+ #: colors.
+ default_char = Char(data=" ", fg="default", bg="default")
+
+ #: An infinite sequence of default characters, used for populating
+ #: new lines and columns.
+ default_line = repeat(default_char)
+
+ def __init__(self, columns, lines):
+ self.savepoints = []
+ self.columns = columns
+ self.lines = lines
+ self.buffer = []
+ self.reset()
+
+ def __repr__(self):
+ return ("{0}({1}, {2})".format(self.__class__.__name__,
+ self.columns, self.lines))
+
+ @property
+ def display(self):
+ """Returns a :func:`list` of screen lines as unicode strings."""
+ def render(line):
+ it = iter(line)
+ while True:
+ char = next(it).data
+ assert sum(map(wcwidth, char[1:])) == 0
+ char_width = wcwidth(char[0])
+ if char_width == 1:
+ yield char
+ elif char_width == 2:
+ yield char
+ next(it) # Skip stub.
+
+ return ["".join(render(line)) for line in self.buffer]
+
+ def reset(self):
+ """Resets the terminal to its initial state.
+
+ * Scroll margins are reset to screen boundaries.
+ * Cursor is moved to home location -- ``(0, 0)`` and its
+ attributes are set to defaults (see :attr:`default_char`).
+ * Screen is cleared -- each character is reset to
+ :attr:`default_char`.
+ * Tabstops are reset to "every eight columns".
+
+ .. note::
+
+ Neither VT220 nor VT102 manuals mentioned that terminal modes
+ and tabstops should be reset as well, thanks to
+ :manpage:`xterm` -- we now know that.
+ """
+ self.buffer[:] = (take(self.columns, self.default_line)
+ for _ in range(self.lines))
+ self.mode = set([mo.DECAWM, mo.DECTCEM])
+ self.margins = Margins(0, self.lines - 1)
+
+ self.title = ""
+ self.icon_name = ""
+
+ self.charset = 0
+ self.g0_charset = cs.LAT1_MAP
+ self.g1_charset = cs.VT100_MAP
+ self.use_utf8 = True
+ self.utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace")
+
+ # From ``man terminfo`` -- "... hardware tabs are initially
+ # set every `n` spaces when the terminal is powered up. Since
+ # we aim to support VT102 / VT220 and linux -- we use n = 8.
+ self.tabstops = set(range(7, self.columns, 8))
+
+ self.cursor = Cursor(0, 0)
+ self.cursor_position()
+
+ def resize(self, lines=None, columns=None):
+ """Resize the screen to the given dimensions.
+
+ If the requested screen size has more lines than the existing
+ screen, lines will be added at the bottom. If the requested
+ size has less lines than the existing screen lines will be
+ clipped at the top of the screen. Similarly, if the existing
+ screen has less columns than the requested screen, columns will
+ be added at the right, and if it has more -- columns will be
+ clipped at the right.
+
+ .. note:: According to `xterm`, we should also reset origin
+ mode and screen margins, see ``xterm/screen.c:1761``.
+
+ :param int lines: number of lines in the new screen.
+ :param int columns: number of columns in the new screen.
+ """
+ lines = lines or self.lines
+ columns = columns or self.columns
+
+ # First resize the lines:
+ diff = self.lines - lines
+
+ # a) if the current display size is less than the requested
+ # size, add lines to the bottom.
+ if diff < 0:
+ self.buffer.extend(take(self.columns, self.default_line)
+ for _ in range(diff, 0))
+ # b) if the current display size is greater than requested
+ # size, take lines off the top.
+ elif diff > 0:
+ self.buffer[:diff] = ()
+
+ # Then resize the columns:
+ diff = self.columns - columns
+
+ # a) if the current display size is less than the requested
+ # size, expand each line to the new size.
+ if diff < 0:
+ for y in range(lines):
+ self.buffer[y].extend(take(abs(diff), self.default_line))
+ # b) if the current display size is greater than requested
+ # size, trim each line from the right to the new size.
+ elif diff > 0:
+ for line in self.buffer:
+ del line[columns:]
+
+ self.lines, self.columns = lines, columns
+ self.margins = Margins(0, self.lines - 1)
+ self.reset_mode(mo.DECOM)
+
+ def set_margins(self, top=None, bottom=None):
+ """Selects top and bottom margins for the scrolling region.
+
+ Margins determine which screen lines move during scrolling
+ (see :meth:`index` and :meth:`reverse_index`). Characters added
+ outside the scrolling region do not cause the screen to scroll.
+
+ :param int top: the smallest line number that is scrolled.
+ :param int bottom: the biggest line number that is scrolled.
+ """
+ if top is None or bottom is None:
+ return
+
+ # Arguments are 1-based, while :attr:`margins` are zero based --
+ # so we have to decrement them by one. We also make sure that
+ # both of them is bounded by [0, lines - 1].
+ top = max(0, min(top - 1, self.lines - 1))
+ bottom = max(0, min(bottom - 1, self.lines - 1))
+
+ # Even though VT102 and VT220 require DECSTBM to ignore regions
+ # of width less than 2, some programs (like aptitude for example)
+ # rely on it. Practicality beats purity.
+ if bottom - top >= 1:
+ self.margins = Margins(top, bottom)
+
+ # The cursor moves to the home position when the top and
+ # bottom margins of the scrolling region (DECSTBM) changes.
+ self.cursor_position()
+
+ def set_mode(self, *modes, **kwargs):
+ """Sets (enables) a given list of modes.
+
+ :param list modes: modes to set, where each mode is a constant
+ from :mod:`pyte.modes`.
+ """
+ # Private mode codes are shifted, to be distingiushed from non
+ # private ones.
+ if kwargs.get("private"):
+ modes = [mode << 5 for mode in modes]
+
+ self.mode.update(modes)
+
+ # When DECOLM mode is set, the screen is erased and the cursor
+ # moves to the home position.
+ if mo.DECCOLM in modes:
+ self.resize(columns=132)
+ self.erase_in_display(2)
+ self.cursor_position()
+
+ # According to `vttest`, DECOM should also home the cursor, see
+ # vttest/main.c:303.
+ if mo.DECOM in modes:
+ self.cursor_position()
+
+ # Mark all displayed characters as reverse.
+ if mo.DECSCNM in modes:
+ self.buffer[:] = ([char._replace(reverse=True) for char in line]
+ for line in self.buffer)
+ self.select_graphic_rendition(7) # +reverse.
+
+ # Make the cursor visible.
+ if mo.DECTCEM in modes:
+ self.cursor.hidden = False
+
+ def reset_mode(self, *modes, **kwargs):
+ """Resets (disables) a given list of modes.
+
+ :param list modes: modes to reset -- hopefully, each mode is a
+ constant from :mod:`pyte.modes`.
+ """
+ # Private mode codes are shifted, to be distinguished from non
+ # private ones.
+ if kwargs.get("private"):
+ modes = [mode << 5 for mode in modes]
+
+ self.mode.difference_update(modes)
+
+ # Lines below follow the logic in :meth:`set_mode`.
+ if mo.DECCOLM in modes:
+ self.resize(columns=80)
+ self.erase_in_display(2)
+ self.cursor_position()
+
+ if mo.DECOM in modes:
+ self.cursor_position()
+
+ if mo.DECSCNM in modes:
+ self.buffer[:] = ([char._replace(reverse=False) for char in line]
+ for line in self.buffer)
+ self.select_graphic_rendition(27) # -reverse.
+
+ # Hide the cursor.
+ if mo.DECTCEM in modes:
+ self.cursor.hidden = True
+
+ def define_charset(self, code, mode):
+ """Defines ``G0`` or ``G1`` charset.
+
+ :param str code: character set code, should be a character
+ from ``b"B0UK"``, otherwise ignored.
+ :param str mode: if ``"("`` ``G0`` charset is defined, if
+ ``")"`` -- we operate on ``G1``.
+
+ .. warning:: User-defined charsets are currently not supported.
+ """
+ if code in cs.MAPS:
+ if mode == b"(":
+ self.g0_charset = cs.MAPS[code]
+ elif mode == b")":
+ self.g1_charset = cs.MAPS[code]
+
+ def shift_in(self):
+ """Selects ``G0`` character set."""
+ self.charset = 0
+
+ def shift_out(self):
+ """Selects ``G1`` character set."""
+ self.charset = 1
+
+ def select_other_charset(self, code):
+ """Selects other (non G0 or G1) charset.
+
+ :param str code: character set code, should be a character from
+ ``b"@G8"``, otherwise ignored.
+
+ .. note:: We currently follow ``"linux"`` and only use this
+ command to switch from ISO-8859-1 to UTF-8 and back.
+
+ .. seealso::
+
+ `Standard ECMA-35, Section 15.4 \
+ `_
+ for a description of VTXXX character set machinery.
+ """
+ if code == b"@":
+ self.use_utf8 = False
+ self.utf8_decoder.reset()
+ elif code in b"G8":
+ self.use_utf8 = True
+
+ def _decode(self, data):
+ """Decodes bytes to text according to the selected charset.
+
+ :param bytes data: bytes to decode.
+ """
+ if self.charset:
+ return "".join(self.g1_charset[b] for b in iter_bytes(data))
+ elif self.use_utf8:
+ return self.utf8_decoder.decode(data)
+ else:
+ return "".join(self.g0_charset[b] for b in iter_bytes(data))
+
+ def draw(self, data):
+ """Displays decoded characters at the current cursor position and
+ advances the cursor if :data:`~pyte.modes.DECAWM` is set.
+
+ :param bytes data: bytes to display.
+
+ .. versionchanged:: 0.5.0
+
+ Character width is taken into account. Specifically, zero-width
+ and unprintable characters do not affect screen state. Full-width
+ characters are rendered into two consecutive character containers.
+
+ .. versionchanged:: 0.6.0
+
+ The input is now supposed to be in :func:`bytes`, which may encode
+ multiple characters.
+ """
+ for char in self._decode(data):
+ char_width = wcwidth(char)
+
+ # If this was the last column in a line and auto wrap mode is
+ # enabled, move the cursor to the beginning of the next line,
+ # otherwise replace characters already displayed with newly
+ # entered.
+ if self.cursor.x == self.columns:
+ if mo.DECAWM in self.mode:
+ self.carriage_return()
+ self.linefeed()
+ elif char_width > 0:
+ self.cursor.x -= char_width
+
+ # If Insert mode is set, new characters move old characters to
+ # the right, otherwise terminal is in Replace mode and new
+ # characters replace old characters at cursor position.
+ if mo.IRM in self.mode and char_width > 0:
+ self.insert_characters(char_width)
+
+ line = self.buffer[self.cursor.y]
+ if char_width == 1:
+ line[self.cursor.x] = self.cursor.attrs._replace(data=char)
+ elif char_width == 2:
+ # A two-cell character has a stub slot after it.
+ line[self.cursor.x] = self.cursor.attrs._replace(data=char)
+ if self.cursor.x + 1 < self.columns:
+ line[self.cursor.x + 1] = self.cursor.attrs._replace(data=" ")
+ elif char_width == 0 and unicodedata.combining(char):
+ # A zero-cell character is combined with the previous
+ # character either on this or preceeding line.
+ if self.cursor.x:
+ last = line[self.cursor.x - 1]
+ normalized = unicodedata.normalize("NFC", last.data + char)
+ line[self.cursor.x - 1] = last._replace(data=normalized)
+ elif self.cursor.y:
+ last = self.buffer[self.cursor.y - 1][self.columns - 1]
+ normalized = unicodedata.normalize("NFC", last.data + char)
+ self.buffer[self.cursor.y - 1][self.columns - 1] = \
+ last._replace(data=normalized)
+ else:
+ pass # Unprintable character or doesn't advance the cursor.
+
+ # .. note:: We can't use :meth:`cursor_forward()`, because that
+ # way, we'll never know when to linefeed.
+ if char_width > 0:
+ self.cursor.x = min(self.cursor.x + char_width, self.columns)
+
+ def set_title(self, param):
+ """Sets terminal title.
+
+ .. note:: This is an XTerm extension supported by the Linux terminal.
+ """
+ self.title = self._decode(param)
+
+ def set_icon_name(self, param):
+ """Sets icon name.
+
+ .. note:: This is an XTerm extension supported by the Linux terminal.
+ """
+ self.icon_name = self._decode(param)
+
+ def carriage_return(self):
+ """Move the cursor to the beginning of the current line."""
+ self.cursor.x = 0
+
+ def index(self):
+ """Move the cursor down one line in the same column. If the
+ cursor is at the last line, create a new line at the bottom.
+ """
+ top, bottom = self.margins
+
+ if self.cursor.y == bottom:
+ self.buffer.pop(top)
+ self.buffer.insert(bottom, take(self.columns, self.default_line))
+ else:
+ self.cursor_down()
+
+ def reverse_index(self):
+ """Move the cursor up one line in the same column. If the cursor
+ is at the first line, create a new line at the top.
+ """
+ top, bottom = self.margins
+
+ if self.cursor.y == top:
+ self.buffer.pop(bottom)
+ self.buffer.insert(top, take(self.columns, self.default_line))
+ else:
+ self.cursor_up()
+
+ def linefeed(self):
+ """Performs an index and, if :data:`~pyte.modes.LNM` is set, a
+ carriage return.
+ """
+ self.index()
+
+ if mo.LNM in self.mode:
+ self.carriage_return()
+
+ self.ensure_bounds()
+
+ def tab(self):
+ """Move to the next tab space, or the end of the screen if there
+ aren't anymore left.
+ """
+ for stop in sorted(self.tabstops):
+ if self.cursor.x < stop:
+ column = stop
+ break
+ else:
+ column = self.columns - 1
+
+ self.cursor.x = column
+
+ def backspace(self):
+ """Move cursor to the left one or keep it in it's position if
+ it's at the beginning of the line already.
+ """
+ self.cursor_back()
+
+ def save_cursor(self):
+ """Push the current cursor position onto the stack."""
+ self.savepoints.append(Savepoint(copy.copy(self.cursor),
+ self.g0_charset,
+ self.g1_charset,
+ self.charset,
+ self.use_utf8,
+ mo.DECOM in self.mode,
+ mo.DECAWM in self.mode))
+
+ def restore_cursor(self):
+ """Set the current cursor position to whatever cursor is on top
+ of the stack.
+ """
+ if self.savepoints:
+ savepoint = self.savepoints.pop()
+
+ self.g0_charset = savepoint.g0_charset
+ self.g1_charset = savepoint.g1_charset
+ self.charset = savepoint.charset
+ self.use_utf8 = savepoint.use_utf8
+
+ if savepoint.origin:
+ self.set_mode(mo.DECOM)
+ if savepoint.wrap:
+ self.set_mode(mo.DECAWM)
+
+ self.cursor = savepoint.cursor
+ self.ensure_bounds(use_margins=True)
+ else:
+ # If nothing was saved, the cursor moves to home position;
+ # origin mode is reset. :todo: DECAWM?
+ self.reset_mode(mo.DECOM)
+ self.cursor_position()
+
+ def insert_lines(self, count=None):
+ """Inserts the indicated # of lines at line with cursor. Lines
+ displayed **at** and below the cursor move down. Lines moved
+ past the bottom margin are lost.
+
+ :param count: number of lines to delete.
+ """
+ count = count or 1
+ top, bottom = self.margins
+
+ # If cursor is outside scrolling margins it -- do nothin'.
+ if top <= self.cursor.y <= bottom:
+ # v +1, because range() is exclusive.
+ for line in range(self.cursor.y,
+ min(bottom + 1, self.cursor.y + count)):
+ self.buffer.pop(bottom)
+ self.buffer.insert(line, take(self.columns, self.default_line))
+
+ self.carriage_return()
+
+ def delete_lines(self, count=None):
+ """Deletes the indicated # of lines, starting at line with
+ cursor. As lines are deleted, lines displayed below cursor
+ move up. Lines added to bottom of screen have spaces with same
+ character attributes as last line moved up.
+
+ :param int count: number of lines to delete.
+ """
+ count = count or 1
+ top, bottom = self.margins
+
+ # If cursor is outside scrolling margins it -- do nothin'.
+ if top <= self.cursor.y <= bottom:
+ # v -- +1 to include the bottom margin.
+ for _ in range(min(bottom - self.cursor.y + 1, count)):
+ self.buffer.pop(self.cursor.y)
+ self.buffer.insert(bottom, list(
+ repeat(self.cursor.attrs, self.columns)))
+
+ self.carriage_return()
+
+ def insert_characters(self, count=None):
+ """Inserts the indicated # of blank characters at the cursor
+ position. The cursor does not move and remains at the beginning
+ of the inserted blank characters. Data on the line is shifted
+ forward.
+
+ :param int count: number of characters to insert.
+ """
+ count = count or 1
+
+ for _ in range(min(self.columns - self.cursor.y, count)):
+ self.buffer[self.cursor.y].insert(self.cursor.x, self.cursor.attrs)
+ self.buffer[self.cursor.y].pop()
+
+ def delete_characters(self, count=None):
+ """Deletes the indicated # of characters, starting with the
+ character at cursor position. When a character is deleted, all
+ characters to the right of cursor move left. Character attributes
+ move with the characters.
+
+ :param int count: number of characters to delete.
+ """
+ count = count or 1
+
+ for _ in range(min(self.columns - self.cursor.x, count)):
+ self.buffer[self.cursor.y].pop(self.cursor.x)
+ self.buffer[self.cursor.y].append(self.cursor.attrs)
+
+ def erase_characters(self, count=None):
+ """Erases the indicated # of characters, starting with the
+ character at cursor position. Character attributes are set
+ cursor attributes. The cursor remains in the same position.
+
+ :param int count: number of characters to erase.
+
+ .. warning::
+
+ Even though *ALL* of the VTXXX manuals state that character
+ attributes **should be reset to defaults**, ``libvte``,
+ ``xterm`` and ``ROTE`` completely ignore this. Same applies
+ too all ``erase_*()`` and ``delete_*()`` methods.
+ """
+ count = count or 1
+
+ for column in range(self.cursor.x,
+ min(self.cursor.x + count, self.columns)):
+ self.buffer[self.cursor.y][column] = self.cursor.attrs
+
+ def erase_in_line(self, how=0, private=False):
+ """Erases a line in a specific way.
+
+ :param int how: defines the way the line should be erased in:
+
+ * ``0`` -- Erases from cursor to end of line, including cursor
+ position.
+ * ``1`` -- Erases from beginning of line to cursor,
+ including cursor position.
+ * ``2`` -- Erases complete line.
+ :param bool private: when ``True`` character attributes are left
+ unchanged **not implemented**.
+ """
+ if how == 0:
+ # a) erase from the cursor to the end of line, including
+ # the cursor,
+ interval = range(self.cursor.x, self.columns)
+ elif how == 1:
+ # b) erase from the beginning of the line to the cursor,
+ # including it,
+ interval = range(self.cursor.x + 1)
+ elif how == 2:
+ # c) erase the entire line.
+ interval = range(self.columns)
+
+ for column in interval:
+ self.buffer[self.cursor.y][column] = self.cursor.attrs
+
+ def erase_in_display(self, how=0, private=False):
+ """Erases display in a specific way.
+
+ :param int how: defines the way the line should be erased in:
+
+ * ``0`` -- Erases from cursor to end of screen, including
+ cursor position.
+ * ``1`` -- Erases from beginning of screen to cursor,
+ including cursor position.
+ * ``2`` -- Erases complete display. All lines are erased
+ and changed to single-width. Cursor does not move.
+ :param bool private: when ``True`` character attributes are left
+ unchanged **not implemented**.
+ """
+ if how == 0:
+ # a) erase from cursor to the end of the display, including
+ # the cursor,
+ interval = range(self.cursor.y + 1, self.lines)
+ elif how == 1:
+ # b) erase from the beginning of the display to the cursor,
+ # including it,
+ interval = range(self.cursor.y)
+ elif how == 2:
+ # c) erase the whole display.
+ interval = range(self.lines)
+
+ for line in interval:
+ self.buffer[line][:] = \
+ (self.cursor.attrs for _ in range(self.columns))
+
+ # In case of 0 or 1 we have to erase the line with the cursor.
+ if how == 0 or how == 1:
+ self.erase_in_line(how)
+
+ def set_tab_stop(self):
+ """Sets a horizontal tab stop at cursor position."""
+ self.tabstops.add(self.cursor.x)
+
+ def clear_tab_stop(self, how=0):
+ """Clears a horizontal tab stop.
+
+ :param int how: defines a way the tab stop should be cleared:
+
+ * ``0`` or nothing -- Clears a horizontal tab stop at cursor
+ position.
+ * ``3`` -- Clears all horizontal tab stops.
+ """
+ if how == 0:
+ # Clears a horizontal tab stop at cursor position, if it's
+ # present, or silently fails if otherwise.
+ self.tabstops.discard(self.cursor.x)
+ elif how == 3:
+ self.tabstops = set() # Clears all horizontal tab stops.
+
+ def ensure_bounds(self, use_margins=None):
+ """Ensure that current cursor position is within screen bounds.
+
+ :param bool use_margins: when ``True`` or when
+ :data:`~pyte.modes.DECOM` is set,
+ cursor is bounded by top and and bottom
+ margins, instead of ``[0; lines - 1]``.
+ """
+ if use_margins or mo.DECOM in self.mode:
+ top, bottom = self.margins
+ else:
+ top, bottom = 0, self.lines - 1
+
+ self.cursor.x = min(max(0, self.cursor.x), self.columns - 1)
+ self.cursor.y = min(max(top, self.cursor.y), bottom)
+
+ def cursor_up(self, count=None):
+ """Moves cursor up the indicated # of lines in same column.
+ Cursor stops at top margin.
+
+ :param int count: number of lines to skip.
+ """
+ self.cursor.y -= count or 1
+ self.ensure_bounds(use_margins=True)
+
+ def cursor_up1(self, count=None):
+ """Moves cursor up the indicated # of lines to column 1. Cursor
+ stops at bottom margin.
+
+ :param int count: number of lines to skip.
+ """
+ self.cursor_up(count)
+ self.carriage_return()
+
+ def cursor_down(self, count=None):
+ """Moves cursor down the indicated # of lines in same column.
+ Cursor stops at bottom margin.
+
+ :param int count: number of lines to skip.
+ """
+ self.cursor.y += count or 1
+ self.ensure_bounds(use_margins=True)
+
+ def cursor_down1(self, count=None):
+ """Moves cursor down the indicated # of lines to column 1.
+ Cursor stops at bottom margin.
+
+ :param int count: number of lines to skip.
+ """
+ self.cursor_down(count)
+ self.carriage_return()
+
+ def cursor_back(self, count=None):
+ """Moves cursor left the indicated # of columns. Cursor stops
+ at left margin.
+
+ :param int count: number of columns to skip.
+ """
+ self.cursor.x -= count or 1
+ self.ensure_bounds()
+
+ def cursor_forward(self, count=None):
+ """Moves cursor right the indicated # of columns. Cursor stops
+ at right margin.
+
+ :param int count: number of columns to skip.
+ """
+ self.cursor.x += count or 1
+ self.ensure_bounds()
+
+ def cursor_position(self, line=None, column=None):
+ """Set the cursor to a specific `line` and `column`.
+
+ Cursor is allowed to move out of the scrolling region only when
+ :data:`~pyte.modes.DECOM` is reset, otherwise -- the position
+ doesn't change.
+
+ :param int line: line number to move the cursor to.
+ :param int column: column number to move the cursor to.
+ """
+ column = (column or 1) - 1
+ line = (line or 1) - 1
+
+ # If origin mode (DECOM) is set, line number are relative to
+ # the top scrolling margin.
+ if mo.DECOM in self.mode:
+ line += self.margins.top
+
+ # Cursor is not allowed to move out of the scrolling region.
+ if not self.margins.top <= line <= self.margins.bottom:
+ return
+
+ self.cursor.x, self.cursor.y = column, line
+ self.ensure_bounds()
+
+ def cursor_to_column(self, column=None):
+ """Moves cursor to a specific column in the current line.
+
+ :param int column: column number to move the cursor to.
+ """
+ self.cursor.x = (column or 1) - 1
+ self.ensure_bounds()
+
+ def cursor_to_line(self, line=None):
+ """Moves cursor to a specific line in the current column.
+
+ :param int line: line number to move the cursor to.
+ """
+ self.cursor.y = (line or 1) - 1
+
+ # If origin mode (DECOM) is set, line number are relative to
+ # the top scrolling margin.
+ if mo.DECOM in self.mode:
+ self.cursor.y += self.margins.top
+
+ # FIXME: should we also restrict the cursor to the scrolling
+ # region?
+
+ self.ensure_bounds()
+
+ def bell(self, *args):
+ """Bell stub -- the actual implementation should probably be
+ provided by the end-user.
+ """
+
+ def alignment_display(self):
+ """Fills screen with uppercase E's for screen focus and alignment."""
+ for line in self.buffer:
+ for column, char in enumerate(line):
+ line[column] = char._replace(data="E")
+
+ def select_graphic_rendition(self, *attrs):
+ """Set display attributes.
+
+ :param list attrs: a list of display attributes to set.
+ """
+ replace = {}
+
+ if not attrs:
+ attrs = [0]
+ else:
+ attrs = list(reversed(attrs))
+
+ while attrs:
+ attr = attrs.pop()
+ if attr in g.FG_ANSI:
+ replace["fg"] = g.FG_ANSI[attr]
+ elif attr in g.BG:
+ replace["bg"] = g.BG_ANSI[attr]
+ elif attr in g.TEXT:
+ attr = g.TEXT[attr]
+ replace[attr[1:]] = attr.startswith("+")
+ elif not attr:
+ replace = self.default_char._asdict()
+ elif attr in g.FG_AIXTERM:
+ replace.update(fg=g.FG_AIXTERM[attr], bold=True)
+ elif attr in g.BG_AIXTERM:
+ replace.update(bg=g.BG_AIXTERM[attr], bold=True)
+ elif attr in (g.FG_256, g.BG_256):
+ key = "fg" if attr == g.FG_256 else "bg"
+ n = attrs.pop()
+ try:
+ if n == 5: # 256.
+ m = attrs.pop()
+ replace[key] = g.FG_BG_256[m]
+ elif n == 2: # 24bit.
+ # This is somewhat non-standard but is nonetheless
+ # supported in quite a few terminals. See discussion
+ # here https://gist.github.com/XVilka/8346728.
+ replace[key] = "{0:02x}{1:02x}{2:02x}".format(
+ attrs.pop(), attrs.pop(), attrs.pop())
+ except IndexError:
+ pass
+
+ self.cursor.attrs = self.cursor.attrs._replace(**replace)
+
+ def report_device_attributes(self, mode=0, **kwargs):
+ """Reports terminal identity.
+
+ .. versionadded:: 0.5.0
+ """
+ # We only implement "primary" DA which is the only DA request
+ # VT102 understood, see ``VT102ID`` in ``linux/drivers/tty/vt.c``.
+ if mode == 0:
+ self.write_process_input(ctrl.CSI + b"?6c")
+
+ def report_device_status(self, mode):
+ """Reports terminal status or cursor position.
+
+ :param int mode: if 5 -- terminal status, 6 -- cursor position,
+ otherwise a noop.
+
+ .. versionadded:: 0.5.0
+ """
+ if mode == 5: # Request for terminal status.
+ self.write_process_input(ctrl.CSI + b"0n")
+ elif mode == 6: # Request for cursor position.
+ x = self.cursor.x + 1
+ y = self.cursor.y + 1
+
+ # "Origin mode (DECOM) selects line numbering."
+ if mo.DECOM in self.mode:
+ y -= self.margins.top
+ self.write_process_input(
+ ctrl.CSI + "{0};{1}R".format(y, x).encode())
+
+ def write_process_input(self, data):
+ """Writes data to the process running inside the terminal.
+
+ By default is a noop.
+
+ :param bytes data: data to write to the process ``stdin``.
+
+ .. versionadded:: 0.5.0
+ """
+
+ def debug(self, *args, **kwargs):
+ """Endpoint for unrecognized escape sequences.
+
+ By default is a noop.
+ """
+
+
+class DiffScreen(Screen):
+ """A screen subclass, which maintains a set of dirty lines in its
+ :attr:`dirty` attribute. The end user is responsible for emptying
+ a set, when a diff is applied.
+
+ .. attribute:: dirty
+
+ A set of line numbers, which should be re-drawn.
+
+ >>> screen = DiffScreen(80, 24)
+ >>> screen.dirty.clear()
+ >>> screen.draw("!")
+ >>> list(screen.dirty)
+ [0]
+ """
+ def __init__(self, *args):
+ self.dirty = set()
+ super(DiffScreen, self).__init__(*args)
+
+ def set_mode(self, *modes, **kwargs):
+ if mo.DECSCNM >> 5 in modes and kwargs.get("private"):
+ self.dirty.update(range(self.lines))
+ super(DiffScreen, self).set_mode(*modes, **kwargs)
+
+ def reset_mode(self, *modes, **kwargs):
+ if mo.DECSCNM >> 5 in modes and kwargs.get("private"):
+ self.dirty.update(range(self.lines))
+ super(DiffScreen, self).reset_mode(*modes, **kwargs)
+
+ def reset(self):
+ self.dirty.update(range(self.lines))
+ super(DiffScreen, self).reset()
+
+ def resize(self, *args, **kwargs):
+ self.dirty.update(range(self.lines))
+ super(DiffScreen, self).resize(*args, **kwargs)
+
+ def draw(self, *args):
+ # Call the superclass's method before marking the row as
+ # dirty, as when wrapping is enabled, draw() might change
+ # self.cursor.y.
+ super(DiffScreen, self).draw(*args)
+ self.dirty.add(self.cursor.y)
+
+ def index(self):
+ if self.cursor.y == self.margins.bottom:
+ self.dirty.update(range(self.lines))
+
+ super(DiffScreen, self).index()
+
+ def reverse_index(self):
+ if self.cursor.y == self.margins.top:
+ self.dirty.update(range(self.lines))
+
+ super(DiffScreen, self).reverse_index()
+
+ def insert_lines(self, *args):
+ self.dirty.update(range(self.cursor.y, self.lines))
+ super(DiffScreen, self).insert_lines(*args)
+
+ def delete_lines(self, *args):
+ self.dirty.update(range(self.cursor.y, self.lines))
+ super(DiffScreen, self).delete_lines(*args)
+
+ def insert_characters(self, *args):
+ self.dirty.add(self.cursor.y)
+ super(DiffScreen, self).insert_characters(*args)
+
+ def delete_characters(self, *args):
+ self.dirty.add(self.cursor.y)
+ super(DiffScreen, self).delete_characters(*args)
+
+ def erase_characters(self, *args):
+ self.dirty.add(self.cursor.y)
+ super(DiffScreen, self).erase_characters(*args)
+
+ def erase_in_line(self, *args):
+ self.dirty.add(self.cursor.y)
+ super(DiffScreen, self).erase_in_line(*args)
+
+ def erase_in_display(self, how=0):
+ if how == 0:
+ self.dirty.update(range(self.cursor.y + 1, self.lines))
+ elif how == 1:
+ self.dirty.update(range(self.cursor.y))
+ elif how == 2:
+ self.dirty.update(range(self.lines))
+
+ super(DiffScreen, self).erase_in_display(how)
+
+ def alignment_display(self):
+ self.dirty.update(range(self.lines))
+ super(DiffScreen, self).alignment_display()
+
+
+History = namedtuple("History", "top bottom ratio size position")
+
+
+class HistoryScreen(DiffScreen):
+ """A screen subclass, which keeps track of screen history and allows
+ pagination. This is not linux-specific, but still useful; see
+ page 462 of VT520 User's Manual.
+
+ :param int history: total number of history lines to keep; is split
+ between top and bottom queues.
+ :param int ratio: defines how much lines to scroll on :meth:`next_page`
+ and :meth:`prev_page` calls.
+
+ .. attribute:: history
+
+ A pair of history queues for top and bottom margins accordingly;
+ here's the overall screen structure::
+
+ [ 1: .......]
+ [ 2: .......] <- top history
+ [ 3: .......]
+ ------------
+ [ 4: .......] s
+ [ 5: .......] c
+ [ 6: .......] r
+ [ 7: .......] e
+ [ 8: .......] e
+ [ 9: .......] n
+ ------------
+ [10: .......]
+ [11: .......] <- bottom history
+ [12: .......]
+
+ .. note::
+
+ Don't forget to update :class:`~pyte.streams.Stream` class with
+ appropriate escape sequences -- you can use any, since pagination
+ protocol is not standardized, for example::
+
+ Stream.escape[b"N"] = "next_page"
+ Stream.escape[b"P"] = "prev_page"
+ """
+ _wrapped = set(Stream.events)
+ _wrapped.update(["next_page", "prev_page"])
+
+ def __init__(self, columns, lines, history=100, ratio=.5):
+ self.history = History(deque(maxlen=history // 2),
+ deque(maxlen=history),
+ float(ratio),
+ history,
+ history)
+
+ super(HistoryScreen, self).__init__(columns, lines)
+
+ def _make_wrapper(self, event, handler):
+ def inner(*args, **kwargs):
+ self.before_event(event)
+ result = handler(*args, **kwargs)
+ self.after_event(event)
+ return result
+ return inner
+
+ def __getattribute__(self, attr):
+ value = super(HistoryScreen, self).__getattribute__(attr)
+ if attr in HistoryScreen._wrapped:
+ return HistoryScreen._make_wrapper(self, attr, value)
+ else:
+ return value
+
+ def before_event(self, event):
+ """Ensures a screen is at the bottom of the history buffer.
+
+ :param str event: event name, for example ``"linefeed"``.
+ """
+ if event not in ["prev_page", "next_page"]:
+ while self.history.position < self.history.size:
+ self.next_page()
+
+ def after_event(self, event):
+ """Ensures all lines on a screen have proper width (:attr:`columns`).
+
+ Extra characters are truncated, missing characters are filled
+ with whitespace.
+
+ :param str event: event name, for example ``"linefeed"``.
+ """
+ if event in ["prev_page", "next_page"]:
+ for idx, line in enumerate(self.buffer):
+ if len(line) > self.columns:
+ self.buffer[idx] = line[:self.columns]
+ elif len(line) < self.columns:
+ self.buffer[idx] = line + take(self.columns - len(line),
+ self.default_line)
+
+ # If we're at the bottom of the history buffer and `DECTCEM`
+ # mode is set -- show the cursor.
+ self.cursor.hidden = not (
+ abs(self.history.position - self.history.size) < self.lines and
+ mo.DECTCEM in self.mode
+ )
+
+ def reset(self):
+ """Overloaded to reset screen history state: history position
+ is reset to bottom of both queues; queues themselves are
+ emptied.
+ """
+ super(HistoryScreen, self).reset()
+
+ self.history.top.clear()
+ self.history.bottom.clear()
+ self.history = self.history._replace(position=self.history.size)
+
+ def index(self):
+ """Overloaded to update top history with the removed lines."""
+ top, bottom = self.margins
+
+ if self.cursor.y == bottom:
+ self.history.top.append(self.buffer[top])
+
+ super(HistoryScreen, self).index()
+
+ def reverse_index(self):
+ """Overloaded to update bottom history with the removed lines."""
+ top, bottom = self.margins
+
+ if self.cursor.y == top:
+ self.history.bottom.append(self.buffer[bottom])
+
+ super(HistoryScreen, self).reverse_index()
+
+ def prev_page(self):
+ """Moves the screen page up through the history buffer. Page
+ size is defined by ``history.ratio``, so for instance
+ ``ratio = .5`` means that half the screen is restored from
+ history on page switch.
+ """
+ if self.history.position > self.lines and self.history.top:
+ mid = min(len(self.history.top),
+ int(math.ceil(self.lines * self.history.ratio)))
+
+ self.history.bottom.extendleft(reversed(self.buffer[-mid:]))
+ self.history = self.history \
+ ._replace(position=self.history.position - self.lines)
+
+ self.buffer[:] = list(reversed([
+ self.history.top.pop() for _ in range(mid)
+ ])) + self.buffer[:-mid]
+
+ self.dirty = set(range(self.lines))
+
+ def next_page(self):
+ """Moves the screen page down through the history buffer."""
+ if self.history.position < self.history.size and self.history.bottom:
+ mid = min(len(self.history.bottom),
+ int(math.ceil(self.lines * self.history.ratio)))
+
+ self.history.top.extend(self.buffer[:mid])
+ self.history = self.history \
+ ._replace(position=self.history.position + self.lines)
+
+ self.buffer[:] = self.buffer[mid:] + [
+ self.history.bottom.popleft() for _ in range(mid)
+ ]
+
+ self.dirty = set(range(self.lines))
diff --git a/pyte/streams.py b/pyte/streams.py
new file mode 100644
index 000000000..1bfcc7795
--- /dev/null
+++ b/pyte/streams.py
@@ -0,0 +1,410 @@
+# -*- coding: utf-8 -*-
+"""
+ pyte.streams
+ ~~~~~~~~~~~~
+
+ This module provides three stream implementations with different
+ features; for starters, here's a quick example of how streams are
+ typically used:
+
+ >>> import pyte
+ >>> screen = pyte.Screen(80, 24)
+ >>> stream = pyte.Stream(screen)
+ >>> stream.feed(b"\x1B[5B") # Move the cursor down 5 rows.
+ >>> screen.cursor.y
+ 5
+
+ :copyright: (c) 2011-2012 by Selectel.
+ :copyright: (c) 2012-2016 by pyte authors and contributors,
+ see AUTHORS for details.
+ :license: LGPL, see LICENSE for more details.
+"""
+
+from __future__ import absolute_import, unicode_literals
+
+import itertools
+import os
+import re
+import sys
+import warnings
+from collections import defaultdict
+
+from . import control as ctrl, escape as esc
+from .compat import str
+
+
+class Stream(object):
+ """A stream is a state machine that parses a stream of bytes and
+ dispatches events based on what it sees.
+
+ :param pyte.screens.Screen screen: a screen to dispatch events to.
+ :param bool strict: check if a given screen implements all required
+ events.
+
+ .. note::
+
+ Stream only accepts :func:`bytes` as input. Decoding it into text
+ is the responsibility of the :class:`~pyte.screens.Screen`.
+
+ .. versionchanged 0.6.0::
+
+ For performance reasons the binding between stream events and
+ screen methods was made static. As a result, the stream **will
+ not** dispatch events to methods added to screen **after** the
+ stream was created.
+
+ .. seealso::
+
+ `man console_codes `_
+ For details on console codes listed bellow in :attr:`basic`,
+ :attr:`escape`, :attr:`csi`, :attr:`sharp` and :attr:`percent`.
+ """
+
+ #: Control sequences, which don't require any arguments.
+ basic = {
+ ctrl.BEL: "bell",
+ ctrl.BS: "backspace",
+ ctrl.HT: "tab",
+ ctrl.LF: "linefeed",
+ ctrl.VT: "linefeed",
+ ctrl.FF: "linefeed",
+ ctrl.CR: "carriage_return",
+ ctrl.SO: "shift_out",
+ ctrl.SI: "shift_in",
+ }
+
+ #: non-CSI escape sequences.
+ escape = {
+ esc.RIS: "reset",
+ esc.IND: "index",
+ esc.NEL: "linefeed",
+ esc.RI: "reverse_index",
+ esc.HTS: "set_tab_stop",
+ esc.DECSC: "save_cursor",
+ esc.DECRC: "restore_cursor",
+ }
+
+ #: "sharp" escape sequences -- ``ESC # ``.
+ sharp = {
+ esc.DECALN: "alignment_display",
+ }
+
+ #: CSI escape sequences -- ``CSI P1;P2;...;Pn ``.
+ csi = {
+ esc.ICH: "insert_characters",
+ esc.CUU: "cursor_up",
+ esc.CUD: "cursor_down",
+ esc.CUF: "cursor_forward",
+ esc.CUB: "cursor_back",
+ esc.CNL: "cursor_down1",
+ esc.CPL: "cursor_up1",
+ esc.CHA: "cursor_to_column",
+ esc.CUP: "cursor_position",
+ esc.ED: "erase_in_display",
+ esc.EL: "erase_in_line",
+ esc.IL: "insert_lines",
+ esc.DL: "delete_lines",
+ esc.DCH: "delete_characters",
+ esc.ECH: "erase_characters",
+ esc.HPR: "cursor_forward",
+ esc.DA: "report_device_attributes",
+ esc.VPA: "cursor_to_line",
+ esc.VPR: "cursor_down",
+ esc.HVP: "cursor_position",
+ esc.TBC: "clear_tab_stop",
+ esc.SM: "set_mode",
+ esc.RM: "reset_mode",
+ esc.SGR: "select_graphic_rendition",
+ esc.DSR: "report_device_status",
+ esc.DECSTBM: "set_margins",
+ esc.HPA: "cursor_to_column"
+ }
+
+ #: A set of all events dispatched by the stream.
+ events = frozenset(itertools.chain(
+ basic.values(), escape.values(), sharp.values(), csi.values(),
+ ["define_charset", "select_other_charset"],
+ ["set_icon", "set_title"], # OSC.
+ ["draw", "debug"]))
+
+ #: A regular expression pattern matching everything what can be
+ #: considered plain text.
+ _special = set([ctrl.ESC, ctrl.CSI, ctrl.NUL, ctrl.DEL, ctrl.OSC])
+ _special.update(basic)
+ _text_pattern = re.compile(
+ b"[^" + b"".join(map(re.escape, _special)) + b"]+")
+ del _special
+
+ def __init__(self, screen=None, strict=True):
+ self.listener = None
+ self.strict = False
+
+ if screen is not None:
+ self.attach(screen)
+
+ def attach(self, screen, only=()):
+ """Adds a given screen to the listener queue.
+
+ :param pyte.screens.Screen screen: a screen to attach to.
+ :param list only: a list of events you want to dispatch to a
+ given screen (empty by default, which means
+ -- dispatch all events).
+ """
+ if self.strict:
+ for event in self.events:
+ if not hasattr(screen, event):
+ error_message = "{0} is missing {1}".format(screen, event)
+ raise TypeError(error_message)
+ if self.listener is not None:
+ warnings.warn("As of version 0.6.0 the listener queue is "
+ "restricted to a single element. Existing "
+ "listener {0} will be replaced."
+ .format(self.listener), DeprecationWarning)
+
+ self.listener = screen
+ self._parser = self._parser_fsm()
+ self._taking_plain_text = next(self._parser)
+
+ def detach(self, screen):
+ """Removes a given screen from the listener queue and fails
+ silently if it's not attached.
+
+ :param pyte.screens.Screen screen: a screen to detach.
+ """
+ if screen is self.listener:
+ self.listener = None
+
+ def feed(self, data):
+ """Consumes a string and advances the state as necessary.
+
+ :param bytes data: a blob of data to feed from.
+ """
+ if isinstance(data, str):
+ warnings.warn("As of version 0.6.0 ``pyte.streams.Stream.feed``"
+ "requires input in bytes. This warnings will become "
+ "and error in 0.6.1.")
+ data = data.encode("utf-8")
+ elif not isinstance(data, bytes):
+ raise TypeError("{0} requires bytes input"
+ .format(self.__class__.__name__))
+
+ send = self._parser.send
+ draw = self.listener.draw
+ match_text = self._text_pattern.match
+ taking_plain_text = self._taking_plain_text
+
+ # TODO: use memoryview?
+ length = len(data)
+ offset = 0
+ while offset < length:
+ if taking_plain_text:
+ match = match_text(data, offset)
+ if match:
+ start, offset = match.span()
+ draw(data[start:offset])
+ else:
+ taking_plain_text = False
+ else:
+ taking_plain_text = send(data[offset:offset + 1])
+ offset += 1
+
+ self._taking_plain_text = taking_plain_text
+
+ def _parser_fsm(self):
+ """An FSM implemented as a coroutine.
+
+ This generator is not the most beautiful, but it is as performant
+ as possible. When a process generates a lot of output, then this
+ will be the bottleneck, because it processes just one character
+ at a time.
+
+ We did many manual optimizations to this function in order to make
+ it as efficient as possible. Don't change anything without profiling
+ first.
+ """
+ basic = self.basic
+ listener = self.listener
+ draw = listener.draw
+ debug = listener.debug
+
+ ESC, CSI = ctrl.ESC, ctrl.CSI
+ OSC, ST = ctrl.OSC, ctrl.ST
+ SP_OR_GT = ctrl.SP + b">"
+ NUL_OR_DEL = ctrl.NUL + ctrl.DEL
+ CAN_OR_SUB = ctrl.CAN + ctrl.SUB
+ ALLOWED_IN_CSI = b"".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF,
+ ctrl.VT, ctrl.FF, ctrl.CR])
+
+ def create_dispatcher(mapping):
+ return defaultdict(lambda: debug, dict(
+ (event, getattr(listener, attr))
+ for event, attr in mapping.items()))
+
+ basic_dispatch = create_dispatcher(basic)
+ sharp_dispatch = create_dispatcher(self.sharp)
+ escape_dispatch = create_dispatcher(self.escape)
+ csi_dispatch = create_dispatcher(self.csi)
+
+ while True:
+ # ``True`` tells ``Screen.feed`` that it is allowed to send
+ # chunks of plain text directly to the listener, instead
+ # of this generator.)
+ char = yield True
+
+ if char == ESC:
+ # Most non-VT52 commands start with a left-bracket after the
+ # escape and then a stream of parameters and a command; with
+ # a single notable exception -- :data:`escape.DECOM` sequence,
+ # which starts with a sharp.
+ #
+ # .. versionchanged:: 0.4.10
+ #
+ # For compatibility with Linux terminal stream also
+ # recognizes ``ESC % C`` sequences for selecting control
+ # character set. However, in the current version these
+ # are noop.
+ char = yield
+ if char == b"[":
+ char = CSI # Go to CSI.
+ elif char == b"]":
+ char = OSC # Go to OSC.
+ else:
+ if char == b"#":
+ sharp_dispatch[(yield)]()
+ if char == b"%":
+ listener.select_other_charset((yield))
+ elif char in b"()":
+ listener.define_charset((yield), mode=char)
+ else:
+ escape_dispatch[char]()
+ continue # Don't go to CSI.
+
+ if char in basic:
+ basic_dispatch[char]()
+ elif char == CSI:
+ # All parameters are unsigned, positive decimal integers, with
+ # the most significant digit sent first. Any parameter greater
+ # than 9999 is set to 9999. If you do not specify a value, a 0
+ # value is assumed.
+ #
+ # .. seealso::
+ #
+ # `VT102 User Guide `_
+ # For details on the formatting of escape arguments.
+ #
+ # `VT220 Programmer Ref. `_
+ # For details on the characters valid for use as
+ # arguments.
+ params = []
+ current = bytearray()
+ private = False
+ while True:
+ char = yield
+ if char == b"?":
+ private = True
+ elif char in ALLOWED_IN_CSI:
+ basic_dispatch[char]()
+ elif char in SP_OR_GT:
+ # We don't handle secondary DA atm.
+ pass
+ elif char in CAN_OR_SUB:
+ # If CAN or SUB is received during a sequence, the
+ # current sequence is aborted; terminal displays
+ # the substitute character, followed by characters
+ # in the sequence received after CAN or SUB.
+ draw(char)
+ break
+ elif char.isdigit():
+ current.extend(char)
+ else:
+ params.append(min(int(bytes(current) or 0), 9999))
+
+ if char == b";":
+ current = bytearray()
+ else:
+ if private:
+ csi_dispatch[char](*params, private=True)
+ else:
+ csi_dispatch[char](*params)
+ break # CSI is finished.
+ elif char == OSC:
+ code = yield
+ param = bytearray()
+ while True:
+ char = yield
+ if char == ST or char == ctrl.BEL:
+ break
+ else:
+ param.extend(char)
+
+ param = bytes(param[1:]) # Drop the ;.
+ if code in b"01":
+ listener.set_icon_name(param)
+ if code in b"02":
+ listener.set_title(param)
+ elif char not in NUL_OR_DEL:
+ draw(char)
+
+
+class ByteStream(Stream):
+ def __init__(self, *args, **kwargs):
+ warnings.warn("As of version 0.6.0 ``pyte.streams.ByteStream`` is an "
+ "alias for ``pyte.streams.Stream``. The former will be "
+ "removed in pyte 0.6.1.", DeprecationWarning)
+
+ if kwargs.pop("encodings", None):
+ warnings.warn(
+ "As of version 0.6.0 ``pyte.streams.ByteStream`` no longer "
+ "decodes input.", DeprecationWarning)
+
+ super(ByteStream, self).__init__(*args, **kwargs)
+
+
+class DebugStream(Stream):
+ r"""Stream, which dumps a subset of the dispatched events to a given
+ file-like object (:data:`sys.stdout` by default).
+
+ >>> import io
+ >>> with io.StringIO() as buf:
+ ... stream = DebugStream(to=buf)
+ ... stream.feed(b"\x1b[1;24r\x1b[4l\x1b[24;1H\x1b[0;10m")
+ ... print(buf.getvalue())
+ ...
+ ... # doctest: +NORMALIZE_WHITESPACE
+ SET_MARGINS 1; 24
+ RESET_MODE 4
+ CURSOR_POSITION 24; 1
+ SELECT_GRAPHIC_RENDITION 0; 10
+
+ :param file to: a file-like object to write debug information to.
+ :param list only: a list of events you want to debug (empty by
+ default, which means -- debug all events).
+ """
+
+ def __init__(self, to=sys.stdout, only=(), *args, **kwargs):
+ def safe_str(chunk):
+ if isinstance(chunk, bytes):
+ chunk = chunk.decode("utf-8")
+ elif not isinstance(chunk, str):
+ chunk = str(chunk)
+
+ return chunk
+
+ def noop(*args, **kwargs):
+ pass
+
+ class Bugger(object):
+ def __getattr__(self, event):
+ if only and event not in only:
+ return noop
+
+ def inner(*args, **kwargs):
+ to.write(event.upper() + " ")
+ to.write("; ".join(map(safe_str, args)))
+ to.write(" ")
+ to.write(", ".join("{0}: {1}".format(k, safe_str(v))
+ for k, v in kwargs.items()))
+ to.write(os.linesep)
+ return inner
+
+ super(DebugStream, self).__init__(Bugger(), *args, **kwargs)
diff --git a/session.vim b/session.vim
index f3e572555..1edd24e9f 100644
--- a/session.vim
+++ b/session.vim
@@ -1,5 +1,5 @@
" Scan the following dirs recursively for tags
-let g:project_tags_dirs = ['kitty']
+let g:project_tags_dirs = ['kitty', 'pyte']
let g:syntastic_python_flake8_exec = 'flake8'
let g:ycm_python_binary_path = 'python3'
set wildignore+==template.py