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