1# -*- coding: utf-8 -*- 2""" 3 pyte.screens 4 ~~~~~~~~~~~~ 5 6 This module provides classes for terminal screens, currently 7 it contains three screens with different features: 8 9 * :class:`~pyte.screens.Screen` -- base screen implementation, 10 which handles all the core escape sequences, recognized by 11 :class:`~pyte.streams.Stream`. 12 * If you need a screen to keep track of the changed lines 13 (which you probably do need) -- use 14 :class:`~pyte.screens.DiffScreen`. 15 * If you also want a screen to collect history and allow 16 pagination -- :class:`pyte.screen.HistoryScreen` is here 17 for ya ;) 18 19 .. note:: It would be nice to split those features into mixin 20 classes, rather than subclasses, but it's not obvious 21 how to do -- feel free to submit a pull request. 22 23 :copyright: (c) 2011-2012 by Selectel. 24 :copyright: (c) 2012-2017 by pyte authors and contributors, 25 see AUTHORS for details. 26 :license: LGPL, see LICENSE for more details. 27""" 28 29from __future__ import absolute_import, unicode_literals, division 30 31import copy 32import json 33import math 34import os 35import sys 36import unicodedata 37import warnings 38from collections import deque, namedtuple, defaultdict 39 40from wcwidth import wcwidth 41 42# There is no standard 2.X backport for ``lru_cache``. 43if sys.version_info >= (3, 2): 44 from functools import lru_cache 45 wcwidth = lru_cache(maxsize=4096)(wcwidth) 46 47from . import ( 48 charsets as cs, 49 control as ctrl, 50 graphics as g, 51 modes as mo 52) 53from .compat import map, range, str 54from .streams import Stream 55 56 57#: A container for screen's scroll margins. 58Margins = namedtuple("Margins", "top bottom") 59 60#: A container for savepoint, created on :data:`~pyte.escape.DECSC`. 61Savepoint = namedtuple("Savepoint", [ 62 "cursor", 63 "g0_charset", 64 "g1_charset", 65 "charset", 66 "origin", 67 "wrap" 68]) 69 70 71class Char(namedtuple("Char", [ 72 "data", 73 "fg", 74 "bg", 75 "bold", 76 "italics", 77 "underscore", 78 "strikethrough", 79 "reverse", 80])): 81 """A single styled on-screen character. 82 83 :param str data: unicode character. Invariant: ``len(data) == 1``. 84 :param str fg: foreground colour. Defaults to ``"default"``. 85 :param str bg: background colour. Defaults to ``"default"``. 86 :param bool bold: flag for rendering the character using bold font. 87 Defaults to ``False``. 88 :param bool italics: flag for rendering the character using italic font. 89 Defaults to ``False``. 90 :param bool underscore: flag for rendering the character underlined. 91 Defaults to ``False``. 92 :param bool strikethrough: flag for rendering the character with a 93 strike-through line. Defaults to ``False``. 94 :param bool reverse: flag for swapping foreground and background colours 95 during rendering. Defaults to ``False``. 96 """ 97 __slots__ = () 98 99 def __new__(cls, data, fg="default", bg="default", bold=False, 100 italics=False, underscore=False, 101 strikethrough=False, reverse=False): 102 return super(Char, cls).__new__(cls, data, fg, bg, bold, italics, 103 underscore, strikethrough, reverse) 104 105 106class Cursor(object): 107 """Screen cursor. 108 109 :param int x: 0-based horizontal cursor position. 110 :param int y: 0-based vertical cursor position. 111 :param pyte.screens.Char attrs: cursor attributes (see 112 :meth:`~pyte.screens.Screen.select_graphic_rendition` 113 for details). 114 """ 115 __slots__ = ("x", "y", "attrs", "hidden") 116 117 def __init__(self, x, y, attrs=Char(" ")): 118 self.x = x 119 self.y = y 120 self.attrs = attrs 121 self.hidden = False 122 123 124class StaticDefaultDict(dict): 125 """A :func:`dict` with a static default value. 126 127 Unlike :func:`collections.defaultdict` this implementation does not 128 implicitly update the mapping when queried with a missing key. 129 130 >>> d = StaticDefaultDict(42) 131 >>> d["foo"] 132 42 133 >>> d 134 {} 135 """ 136 def __init__(self, default): 137 self.default = default 138 139 def __missing__(self, key): 140 return self.default 141 142 143class Screen(object): 144 """ 145 A screen is an in-memory matrix of characters that represents the 146 screen display of the terminal. It can be instantiated on its own 147 and given explicit commands, or it can be attached to a stream and 148 will respond to events. 149 150 .. attribute:: buffer 151 152 A sparse ``lines x columns`` :class:`~pyte.screens.Char` matrix. 153 154 .. attribute:: dirty 155 156 A set of line numbers, which should be re-drawn. The user is responsible 157 for clearing this set when changes have been applied. 158 159 >>> screen = Screen(80, 24) 160 >>> screen.dirty.clear() 161 >>> screen.draw("!") 162 >>> list(screen.dirty) 163 [0] 164 165 .. versionadded:: 0.7.0 166 167 .. attribute:: cursor 168 169 Reference to the :class:`~pyte.screens.Cursor` object, holding 170 cursor position and attributes. 171 172 .. attribute:: margins 173 174 Margins determine which screen lines move during scrolling 175 (see :meth:`index` and :meth:`reverse_index`). Characters added 176 outside the scrolling region do not make the screen to scroll. 177 178 The value is ``None`` if margins are set to screen boundaries, 179 otherwise -- a pair 0-based top and bottom line indices. 180 181 .. attribute:: charset 182 183 Current charset number; can be either ``0`` or ``1`` for `G0` 184 and `G1` respectively, note that `G0` is activated by default. 185 186 .. note:: 187 188 According to ``ECMA-48`` standard, **lines and columns are 189 1-indexed**, so, for instance ``ESC [ 10;10 f`` really means 190 -- move cursor to position (9, 9) in the display matrix. 191 192 .. versionchanged:: 0.4.7 193 .. warning:: 194 195 :data:`~pyte.modes.LNM` is reset by default, to match VT220 196 specification. Unfortunatelly this makes :mod:`pyte` fail 197 ``vttest`` for cursor movement. 198 199 .. versionchanged:: 0.4.8 200 .. warning:: 201 202 If `DECAWM` mode is set than a cursor will be wrapped to the 203 **beginning** of the next line, which is the behaviour described 204 in ``man console_codes``. 205 206 .. seealso:: 207 208 `Standard ECMA-48, Section 6.1.1 \ 209 <http://ecma-international.org/publications/standards/Ecma-048.htm>`_ 210 for a description of the presentational component, implemented 211 by ``Screen``. 212 """ 213 @property 214 def default_char(self): 215 """An empty character with default foreground and background colors.""" 216 reverse = mo.DECSCNM in self.mode 217 return Char(data=" ", fg="default", bg="default", reverse=reverse) 218 219 def __init__(self, columns, lines): 220 self.savepoints = [] 221 self.columns = columns 222 self.lines = lines 223 self.buffer = defaultdict(lambda: StaticDefaultDict(self.default_char)) 224 self.dirty = set() 225 self.reset() 226 227 def __repr__(self): 228 return ("{0}({1}, {2})".format(self.__class__.__name__, 229 self.columns, self.lines)) 230 231 @property 232 def display(self): 233 """A :func:`list` of screen lines as unicode strings.""" 234 def render(line): 235 is_wide_char = False 236 for x in range(self.columns): 237 if is_wide_char: # Skip stub 238 is_wide_char = False 239 continue 240 char = line[x].data 241 assert sum(map(wcwidth, char[1:])) == 0 242 is_wide_char = wcwidth(char[0]) == 2 243 yield char 244 245 return ["".join(render(self.buffer[y])) for y in range(self.lines)] 246 247 def reset(self): 248 """Reset the terminal to its initial state. 249 250 * Scrolling margins are reset to screen boundaries. 251 * Cursor is moved to home location -- ``(0, 0)`` and its 252 attributes are set to defaults (see :attr:`default_char`). 253 * Screen is cleared -- each character is reset to 254 :attr:`default_char`. 255 * Tabstops are reset to "every eight columns". 256 * All lines are marked as :attr:`dirty`. 257 258 .. note:: 259 260 Neither VT220 nor VT102 manuals mention that terminal modes 261 and tabstops should be reset as well, thanks to 262 :manpage:`xterm` -- we now know that. 263 """ 264 self.dirty.update(range(self.lines)) 265 self.buffer.clear() 266 self.margins = None 267 268 self.mode = set([mo.DECAWM, mo.DECTCEM]) 269 270 self.title = "" 271 self.icon_name = "" 272 273 self.charset = 0 274 self.g0_charset = cs.LAT1_MAP 275 self.g1_charset = cs.VT100_MAP 276 277 # From ``man terminfo`` -- "... hardware tabs are initially 278 # set every `n` spaces when the terminal is powered up. Since 279 # we aim to support VT102 / VT220 and linux -- we use n = 8. 280 self.tabstops = set(range(8, self.columns, 8)) 281 282 self.cursor = Cursor(0, 0) 283 self.cursor_position() 284 285 self.saved_columns = None 286 287 def resize(self, lines=None, columns=None): 288 """Resize the screen to the given size. 289 290 If the requested screen size has more lines than the existing 291 screen, lines will be added at the bottom. If the requested 292 size has less lines than the existing screen lines will be 293 clipped at the top of the screen. Similarly, if the existing 294 screen has less columns than the requested screen, columns will 295 be added at the right, and if it has more -- columns will be 296 clipped at the right. 297 298 :param int lines: number of lines in the new screen. 299 :param int columns: number of columns in the new screen. 300 301 .. versionchanged:: 0.7.0 302 303 If the requested screen size is identical to the current screen 304 size, the method does nothing. 305 """ 306 lines = lines or self.lines 307 columns = columns or self.columns 308 309 if lines == self.lines and columns == self.columns: 310 return # No changes. 311 312 self.dirty.update(range(lines)) 313 314 if lines < self.lines: 315 self.save_cursor() 316 self.cursor_position(0, 0) 317 self.delete_lines(self.lines - lines) # Drop from the top. 318 self.restore_cursor() 319 320 if columns < self.columns: 321 for line in self.buffer.values(): 322 for x in range(columns, self.columns): 323 line.pop(x, None) 324 325 self.lines, self.columns = lines, columns 326 self.set_margins() 327 328 def set_margins(self, top=None, bottom=None): 329 """Select top and bottom margins for the scrolling region. 330 331 :param int top: the smallest line number that is scrolled. 332 :param int bottom: the biggest line number that is scrolled. 333 """ 334 if top is None and bottom is None: 335 self.margins = None 336 return 337 338 margins = self.margins or Margins(0, self.lines - 1) 339 340 # Arguments are 1-based, while :attr:`margins` are zero 341 # based -- so we have to decrement them by one. We also 342 # make sure that both of them is bounded by [0, lines - 1]. 343 if top is None: 344 top = margins.top 345 else: 346 top = max(0, min(top - 1, self.lines - 1)) 347 if bottom is None: 348 bottom = margins.bottom 349 else: 350 bottom = max(0, min(bottom - 1, self.lines - 1)) 351 352 # Even though VT102 and VT220 require DECSTBM to ignore 353 # regions of width less than 2, some programs (like aptitude 354 # for example) rely on it. Practicality beats purity. 355 if bottom - top >= 1: 356 self.margins = Margins(top, bottom) 357 358 # The cursor moves to the home position when the top and 359 # bottom margins of the scrolling region (DECSTBM) changes. 360 self.cursor_position() 361 362 def set_mode(self, *modes, **kwargs): 363 """Set (enable) a given list of modes. 364 365 :param list modes: modes to set, where each mode is a constant 366 from :mod:`pyte.modes`. 367 """ 368 # Private mode codes are shifted, to be distingiushed from non 369 # private ones. 370 if kwargs.get("private"): 371 modes = [mode << 5 for mode in modes] 372 if mo.DECSCNM in modes: 373 self.dirty.update(range(self.lines)) 374 375 self.mode.update(modes) 376 377 # When DECOLM mode is set, the screen is erased and the cursor 378 # moves to the home position. 379 if mo.DECCOLM in modes: 380 self.saved_columns = self.columns 381 self.resize(columns=132) 382 self.erase_in_display(2) 383 self.cursor_position() 384 385 # According to VT520 manual, DECOM should also home the cursor. 386 if mo.DECOM in modes: 387 self.cursor_position() 388 389 # Mark all displayed characters as reverse. 390 if mo.DECSCNM in modes: 391 for line in self.buffer.values(): 392 line.default = self.default_char 393 for x in line: 394 line[x] = line[x]._replace(reverse=True) 395 396 self.select_graphic_rendition(7) # +reverse. 397 398 # Make the cursor visible. 399 if mo.DECTCEM in modes: 400 self.cursor.hidden = False 401 402 def reset_mode(self, *modes, **kwargs): 403 """Reset (disable) a given list of modes. 404 405 :param list modes: modes to reset -- hopefully, each mode is a 406 constant from :mod:`pyte.modes`. 407 """ 408 # Private mode codes are shifted, to be distinguished from non 409 # private ones. 410 if kwargs.get("private"): 411 modes = [mode << 5 for mode in modes] 412 if mo.DECSCNM in modes: 413 self.dirty.update(range(self.lines)) 414 415 self.mode.difference_update(modes) 416 417 # Lines below follow the logic in :meth:`set_mode`. 418 if mo.DECCOLM in modes: 419 if self.columns == 132 and self.saved_columns is not None: 420 self.resize(columns=self.saved_columns) 421 self.saved_columns = None 422 self.erase_in_display(2) 423 self.cursor_position() 424 425 if mo.DECOM in modes: 426 self.cursor_position() 427 428 if mo.DECSCNM in modes: 429 for line in self.buffer.values(): 430 line.default = self.default_char 431 for x in line: 432 line[x] = line[x]._replace(reverse=False) 433 434 self.select_graphic_rendition(27) # -reverse. 435 436 # Hide the cursor. 437 if mo.DECTCEM in modes: 438 self.cursor.hidden = True 439 440 def define_charset(self, code, mode): 441 """Define ``G0`` or ``G1`` charset. 442 443 :param str code: character set code, should be a character 444 from ``"B0UK"``, otherwise ignored. 445 :param str mode: if ``"("`` ``G0`` charset is defined, if 446 ``")"`` -- we operate on ``G1``. 447 448 .. warning:: User-defined charsets are currently not supported. 449 """ 450 if code in cs.MAPS: 451 if mode == "(": 452 self.g0_charset = cs.MAPS[code] 453 elif mode == ")": 454 self.g1_charset = cs.MAPS[code] 455 456 def shift_in(self): 457 """Select ``G0`` character set.""" 458 self.charset = 0 459 460 def shift_out(self): 461 """Select ``G1`` character set.""" 462 self.charset = 1 463 464 def draw(self, data): 465 """Display decoded characters at the current cursor position and 466 advances the cursor if :data:`~pyte.modes.DECAWM` is set. 467 468 :param str data: text to display. 469 470 .. versionchanged:: 0.5.0 471 472 Character width is taken into account. Specifically, zero-width 473 and unprintable characters do not affect screen state. Full-width 474 characters are rendered into two consecutive character containers. 475 """ 476 data = data.translate( 477 self.g1_charset if self.charset else self.g0_charset) 478 479 for char in data: 480 char_width = wcwidth(char) 481 482 # If this was the last column in a line and auto wrap mode is 483 # enabled, move the cursor to the beginning of the next line, 484 # otherwise replace characters already displayed with newly 485 # entered. 486 if self.cursor.x == self.columns: 487 if mo.DECAWM in self.mode: 488 self.dirty.add(self.cursor.y) 489 self.carriage_return() 490 self.linefeed() 491 elif char_width > 0: 492 self.cursor.x -= char_width 493 494 # If Insert mode is set, new characters move old characters to 495 # the right, otherwise terminal is in Replace mode and new 496 # characters replace old characters at cursor position. 497 if mo.IRM in self.mode and char_width > 0: 498 self.insert_characters(char_width) 499 500 line = self.buffer[self.cursor.y] 501 if char_width == 1: 502 line[self.cursor.x] = self.cursor.attrs._replace(data=char) 503 elif char_width == 2: 504 # A two-cell character has a stub slot after it. 505 line[self.cursor.x] = self.cursor.attrs._replace(data=char) 506 if self.cursor.x + 1 < self.columns: 507 line[self.cursor.x + 1] = self.cursor.attrs \ 508 ._replace(data="") 509 elif char_width == 0 and unicodedata.combining(char): 510 # A zero-cell character is combined with the previous 511 # character either on this or preceeding line. 512 if self.cursor.x: 513 last = line[self.cursor.x - 1] 514 normalized = unicodedata.normalize("NFC", last.data + char) 515 line[self.cursor.x - 1] = last._replace(data=normalized) 516 elif self.cursor.y: 517 last = self.buffer[self.cursor.y - 1][self.columns - 1] 518 normalized = unicodedata.normalize("NFC", last.data + char) 519 self.buffer[self.cursor.y - 1][self.columns - 1] = \ 520 last._replace(data=normalized) 521 else: 522 break # Unprintable character or doesn't advance the cursor. 523 524 # .. note:: We can't use :meth:`cursor_forward()`, because that 525 # way, we'll never know when to linefeed. 526 if char_width > 0: 527 self.cursor.x = min(self.cursor.x + char_width, self.columns) 528 529 self.dirty.add(self.cursor.y) 530 531 def set_title(self, param): 532 """Set terminal title. 533 534 .. note:: This is an XTerm extension supported by the Linux terminal. 535 """ 536 self.title = param 537 538 def set_icon_name(self, param): 539 """Set icon name. 540 541 .. note:: This is an XTerm extension supported by the Linux terminal. 542 """ 543 self.icon_name = param 544 545 def carriage_return(self): 546 """Move the cursor to the beginning of the current line.""" 547 self.cursor.x = 0 548 549 def index(self): 550 """Move the cursor down one line in the same column. If the 551 cursor is at the last line, create a new line at the bottom. 552 """ 553 top, bottom = self.margins or Margins(0, self.lines - 1) 554 if self.cursor.y == bottom: 555 # TODO: mark only the lines within margins? 556 self.dirty.update(range(self.lines)) 557 for y in range(top, bottom): 558 self.buffer[y] = self.buffer[y + 1] 559 self.buffer.pop(bottom, None) 560 else: 561 self.cursor_down() 562 563 def reverse_index(self): 564 """Move the cursor up one line in the same column. If the cursor 565 is at the first line, create a new line at the top. 566 """ 567 top, bottom = self.margins or Margins(0, self.lines - 1) 568 if self.cursor.y == top: 569 # TODO: mark only the lines within margins? 570 self.dirty.update(range(self.lines)) 571 for y in range(bottom, top, -1): 572 self.buffer[y] = self.buffer[y - 1] 573 self.buffer.pop(top, None) 574 else: 575 self.cursor_up() 576 577 def linefeed(self): 578 """Perform an index and, if :data:`~pyte.modes.LNM` is set, a 579 carriage return. 580 """ 581 self.index() 582 583 if mo.LNM in self.mode: 584 self.carriage_return() 585 586 def tab(self): 587 """Move to the next tab space, or the end of the screen if there 588 aren't anymore left. 589 """ 590 for stop in sorted(self.tabstops): 591 if self.cursor.x < stop: 592 column = stop 593 break 594 else: 595 column = self.columns - 1 596 597 self.cursor.x = column 598 599 def backspace(self): 600 """Move cursor to the left one or keep it in its position if 601 it's at the beginning of the line already. 602 """ 603 self.cursor_back() 604 605 def save_cursor(self): 606 """Push the current cursor position onto the stack.""" 607 self.savepoints.append(Savepoint(copy.copy(self.cursor), 608 self.g0_charset, 609 self.g1_charset, 610 self.charset, 611 mo.DECOM in self.mode, 612 mo.DECAWM in self.mode)) 613 614 def restore_cursor(self): 615 """Set the current cursor position to whatever cursor is on top 616 of the stack. 617 """ 618 if self.savepoints: 619 savepoint = self.savepoints.pop() 620 621 self.g0_charset = savepoint.g0_charset 622 self.g1_charset = savepoint.g1_charset 623 self.charset = savepoint.charset 624 625 if savepoint.origin: 626 self.set_mode(mo.DECOM) 627 if savepoint.wrap: 628 self.set_mode(mo.DECAWM) 629 630 self.cursor = savepoint.cursor 631 self.ensure_hbounds() 632 self.ensure_vbounds(use_margins=True) 633 else: 634 # If nothing was saved, the cursor moves to home position; 635 # origin mode is reset. :todo: DECAWM? 636 self.reset_mode(mo.DECOM) 637 self.cursor_position() 638 639 def insert_lines(self, count=None): 640 """Insert the indicated # of lines at line with cursor. Lines 641 displayed **at** and below the cursor move down. Lines moved 642 past the bottom margin are lost. 643 644 :param count: number of lines to insert. 645 """ 646 count = count or 1 647 top, bottom = self.margins or Margins(0, self.lines - 1) 648 649 # If cursor is outside scrolling margins it -- do nothin'. 650 if top <= self.cursor.y <= bottom: 651 self.dirty.update(range(self.cursor.y, self.lines)) 652 for y in range(bottom, self.cursor.y - 1, -1): 653 if y + count <= bottom and y in self.buffer: 654 self.buffer[y + count] = self.buffer[y] 655 self.buffer.pop(y, None) 656 657 self.carriage_return() 658 659 def delete_lines(self, count=None): 660 """Delete the indicated # of lines, starting at line with 661 cursor. As lines are deleted, lines displayed below cursor 662 move up. Lines added to bottom of screen have spaces with same 663 character attributes as last line moved up. 664 665 :param int count: number of lines to delete. 666 """ 667 count = count or 1 668 top, bottom = self.margins or Margins(0, self.lines - 1) 669 670 # If cursor is outside scrolling margins -- do nothin'. 671 if top <= self.cursor.y <= bottom: 672 self.dirty.update(range(self.cursor.y, self.lines)) 673 for y in range(self.cursor.y, bottom + 1): 674 if y + count <= bottom: 675 if y + count in self.buffer: 676 self.buffer[y] = self.buffer.pop(y + count) 677 else: 678 self.buffer.pop(y, None) 679 680 self.carriage_return() 681 682 def insert_characters(self, count=None): 683 """Insert the indicated # of blank characters at the cursor 684 position. The cursor does not move and remains at the beginning 685 of the inserted blank characters. Data on the line is shifted 686 forward. 687 688 :param int count: number of characters to insert. 689 """ 690 self.dirty.add(self.cursor.y) 691 692 count = count or 1 693 line = self.buffer[self.cursor.y] 694 for x in range(self.columns, self.cursor.x - 1, -1): 695 if x + count <= self.columns: 696 line[x + count] = line[x] 697 line.pop(x, None) 698 699 def delete_characters(self, count=None): 700 """Delete the indicated # of characters, starting with the 701 character at cursor position. When a character is deleted, all 702 characters to the right of cursor move left. Character attributes 703 move with the characters. 704 705 :param int count: number of characters to delete. 706 """ 707 self.dirty.add(self.cursor.y) 708 count = count or 1 709 710 line = self.buffer[self.cursor.y] 711 for x in range(self.cursor.x, self.columns): 712 if x + count <= self.columns: 713 line[x] = line.pop(x + count, self.default_char) 714 else: 715 line.pop(x, None) 716 717 def erase_characters(self, count=None): 718 """Erase the indicated # of characters, starting with the 719 character at cursor position. Character attributes are set 720 cursor attributes. The cursor remains in the same position. 721 722 :param int count: number of characters to erase. 723 724 .. note:: 725 726 Using cursor attributes for character attributes may seem 727 illogical, but if recall that a terminal emulator emulates 728 a type writer, it starts to make sense. The only way a type 729 writer could erase a character is by typing over it. 730 """ 731 self.dirty.add(self.cursor.y) 732 count = count or 1 733 734 line = self.buffer[self.cursor.y] 735 for x in range(self.cursor.x, 736 min(self.cursor.x + count, self.columns)): 737 line[x] = self.cursor.attrs 738 739 def erase_in_line(self, how=0, private=False): 740 """Erase a line in a specific way. 741 742 Character attributes are set to cursor attributes. 743 744 :param int how: defines the way the line should be erased in: 745 746 * ``0`` -- Erases from cursor to end of line, including cursor 747 position. 748 * ``1`` -- Erases from beginning of line to cursor, 749 including cursor position. 750 * ``2`` -- Erases complete line. 751 :param bool private: when ``True`` only characters marked as 752 eraseable are affected **not implemented**. 753 """ 754 self.dirty.add(self.cursor.y) 755 if how == 0: 756 interval = range(self.cursor.x, self.columns) 757 elif how == 1: 758 interval = range(self.cursor.x + 1) 759 elif how == 2: 760 interval = range(self.columns) 761 762 line = self.buffer[self.cursor.y] 763 for x in interval: 764 line[x] = self.cursor.attrs 765 766 def erase_in_display(self, how=0, private=False): 767 """Erases display in a specific way. 768 769 Character attributes are set to cursor attributes. 770 771 :param int how: defines the way the line should be erased in: 772 773 * ``0`` -- Erases from cursor to end of screen, including 774 cursor position. 775 * ``1`` -- Erases from beginning of screen to cursor, 776 including cursor position. 777 * ``2`` and ``3`` -- Erases complete display. All lines 778 are erased and changed to single-width. Cursor does not 779 move. 780 :param bool private: when ``True`` only characters marked as 781 eraseable are affected **not implemented**. 782 """ 783 if how == 0: 784 interval = range(self.cursor.y + 1, self.lines) 785 elif how == 1: 786 interval = range(self.cursor.y) 787 elif how == 2 or how == 3: 788 interval = range(self.lines) 789 790 self.dirty.update(interval) 791 for y in interval: 792 line = self.buffer[y] 793 for x in line: 794 line[x] = self.cursor.attrs 795 796 if how == 0 or how == 1: 797 self.erase_in_line(how) 798 799 def set_tab_stop(self): 800 """Set a horizontal tab stop at cursor position.""" 801 self.tabstops.add(self.cursor.x) 802 803 def clear_tab_stop(self, how=0): 804 """Clear a horizontal tab stop. 805 806 :param int how: defines a way the tab stop should be cleared: 807 808 * ``0`` or nothing -- Clears a horizontal tab stop at cursor 809 position. 810 * ``3`` -- Clears all horizontal tab stops. 811 """ 812 if how == 0: 813 # Clears a horizontal tab stop at cursor position, if it's 814 # present, or silently fails if otherwise. 815 self.tabstops.discard(self.cursor.x) 816 elif how == 3: 817 self.tabstops = set() # Clears all horizontal tab stops. 818 819 def ensure_hbounds(self): 820 """Ensure the cursor is within horizontal screen bounds.""" 821 self.cursor.x = min(max(0, self.cursor.x), self.columns - 1) 822 823 def ensure_vbounds(self, use_margins=None): 824 """Ensure the cursor is within vertical screen bounds. 825 826 :param bool use_margins: when ``True`` or when 827 :data:`~pyte.modes.DECOM` is set, 828 cursor is bounded by top and and bottom 829 margins, instead of ``[0; lines - 1]``. 830 """ 831 if (use_margins or mo.DECOM in self.mode) and self.margins is not None: 832 top, bottom = self.margins 833 else: 834 top, bottom = 0, self.lines - 1 835 836 self.cursor.y = min(max(top, self.cursor.y), bottom) 837 838 def cursor_up(self, count=None): 839 """Move cursor up the indicated # of lines in same column. 840 Cursor stops at top margin. 841 842 :param int count: number of lines to skip. 843 """ 844 top, _bottom = self.margins or Margins(0, self.lines - 1) 845 self.cursor.y = max(self.cursor.y - (count or 1), top) 846 847 def cursor_up1(self, count=None): 848 """Move cursor up the indicated # of lines to column 1. Cursor 849 stops at bottom margin. 850 851 :param int count: number of lines to skip. 852 """ 853 self.cursor_up(count) 854 self.carriage_return() 855 856 def cursor_down(self, count=None): 857 """Move cursor down the indicated # of lines in same column. 858 Cursor stops at bottom margin. 859 860 :param int count: number of lines to skip. 861 """ 862 _top, bottom = self.margins or Margins(0, self.lines - 1) 863 self.cursor.y = min(self.cursor.y + (count or 1), bottom) 864 865 def cursor_down1(self, count=None): 866 """Move cursor down the indicated # of lines to column 1. 867 Cursor stops at bottom margin. 868 869 :param int count: number of lines to skip. 870 """ 871 self.cursor_down(count) 872 self.carriage_return() 873 874 def cursor_back(self, count=None): 875 """Move cursor left the indicated # of columns. Cursor stops 876 at left margin. 877 878 :param int count: number of columns to skip. 879 """ 880 # Handle the case when we've just drawn in the last column 881 # and would wrap the line on the next :meth:`draw()` call. 882 if self.cursor.x == self.columns: 883 self.cursor.x -= 1 884 885 self.cursor.x -= count or 1 886 self.ensure_hbounds() 887 888 def cursor_forward(self, count=None): 889 """Move cursor right the indicated # of columns. Cursor stops 890 at right margin. 891 892 :param int count: number of columns to skip. 893 """ 894 self.cursor.x += count or 1 895 self.ensure_hbounds() 896 897 def cursor_position(self, line=None, column=None): 898 """Set the cursor to a specific `line` and `column`. 899 900 Cursor is allowed to move out of the scrolling region only when 901 :data:`~pyte.modes.DECOM` is reset, otherwise -- the position 902 doesn't change. 903 904 :param int line: line number to move the cursor to. 905 :param int column: column number to move the cursor to. 906 """ 907 column = (column or 1) - 1 908 line = (line or 1) - 1 909 910 # If origin mode (DECOM) is set, line number are relative to 911 # the top scrolling margin. 912 if self.margins is not None and mo.DECOM in self.mode: 913 line += self.margins.top 914 915 # Cursor is not allowed to move out of the scrolling region. 916 if not self.margins.top <= line <= self.margins.bottom: 917 return 918 919 self.cursor.x = column 920 self.cursor.y = line 921 self.ensure_hbounds() 922 self.ensure_vbounds() 923 924 def cursor_to_column(self, column=None): 925 """Move cursor to a specific column in the current line. 926 927 :param int column: column number to move the cursor to. 928 """ 929 self.cursor.x = (column or 1) - 1 930 self.ensure_hbounds() 931 932 def cursor_to_line(self, line=None): 933 """Move cursor to a specific line in the current column. 934 935 :param int line: line number to move the cursor to. 936 """ 937 self.cursor.y = (line or 1) - 1 938 939 # If origin mode (DECOM) is set, line number are relative to 940 # the top scrolling margin. 941 if mo.DECOM in self.mode: 942 self.cursor.y += self.margins.top 943 944 # FIXME: should we also restrict the cursor to the scrolling 945 # region? 946 947 self.ensure_vbounds() 948 949 def bell(self, *args): 950 """Bell stub -- the actual implementation should probably be 951 provided by the end-user. 952 """ 953 954 def alignment_display(self): 955 """Fills screen with uppercase E's for screen focus and alignment.""" 956 self.dirty.update(range(self.lines)) 957 for y in range(self.lines): 958 for x in range(self.columns): 959 self.buffer[y][x] = self.buffer[y][x]._replace(data="E") 960 961 def select_graphic_rendition(self, *attrs): 962 """Set display attributes. 963 964 :param list attrs: a list of display attributes to set. 965 """ 966 replace = {} 967 968 # Fast path for resetting all attributes. 969 if not attrs or attrs == (0, ): 970 self.cursor.attrs = self.default_char 971 return 972 else: 973 attrs = list(reversed(attrs)) 974 975 while attrs: 976 attr = attrs.pop() 977 if attr == 0: 978 # Reset all attributes. 979 replace.update(self.default_char._asdict()) 980 elif attr in g.FG_ANSI: 981 replace["fg"] = g.FG_ANSI[attr] 982 elif attr in g.BG: 983 replace["bg"] = g.BG_ANSI[attr] 984 elif attr in g.TEXT: 985 attr = g.TEXT[attr] 986 replace[attr[1:]] = attr.startswith("+") 987 elif attr in g.FG_AIXTERM: 988 replace.update(fg=g.FG_AIXTERM[attr], bold=True) 989 elif attr in g.BG_AIXTERM: 990 replace.update(bg=g.BG_AIXTERM[attr], bold=True) 991 elif attr in (g.FG_256, g.BG_256): 992 key = "fg" if attr == g.FG_256 else "bg" 993 try: 994 n = attrs.pop() 995 if n == 5: # 256. 996 m = attrs.pop() 997 replace[key] = g.FG_BG_256[m] 998 elif n == 2: # 24bit. 999 # This is somewhat non-standard but is nonetheless 1000 # supported in quite a few terminals. See discussion 1001 # here https://gist.github.com/XVilka/8346728. 1002 replace[key] = "{0:02x}{1:02x}{2:02x}".format( 1003 attrs.pop(), attrs.pop(), attrs.pop()) 1004 except IndexError: 1005 pass 1006 1007 self.cursor.attrs = self.cursor.attrs._replace(**replace) 1008 1009 def report_device_attributes(self, mode=0, **kwargs): 1010 """Report terminal identity. 1011 1012 .. versionadded:: 0.5.0 1013 1014 .. versionchanged:: 0.7.0 1015 1016 If ``private`` keyword argument is set, the method does nothing. 1017 This behaviour is consistent with VT220 manual. 1018 """ 1019 # We only implement "primary" DA which is the only DA request 1020 # VT102 understood, see ``VT102ID`` in ``linux/drivers/tty/vt.c``. 1021 if mode == 0 and not kwargs.get("private"): 1022 self.write_process_input(ctrl.CSI + "?6c") 1023 1024 def report_device_status(self, mode): 1025 """Report terminal status or cursor position. 1026 1027 :param int mode: if 5 -- terminal status, 6 -- cursor position, 1028 otherwise a noop. 1029 1030 .. versionadded:: 0.5.0 1031 """ 1032 if mode == 5: # Request for terminal status. 1033 self.write_process_input(ctrl.CSI + "0n") 1034 elif mode == 6: # Request for cursor position. 1035 x = self.cursor.x + 1 1036 y = self.cursor.y + 1 1037 1038 # "Origin mode (DECOM) selects line numbering." 1039 if mo.DECOM in self.mode: 1040 y -= self.margins.top 1041 self.write_process_input(ctrl.CSI + "{0};{1}R".format(y, x)) 1042 1043 def write_process_input(self, data): 1044 """Write data to the process running inside the terminal. 1045 1046 By default is a noop. 1047 1048 :param str data: text to write to the process ``stdin``. 1049 1050 .. versionadded:: 0.5.0 1051 """ 1052 1053 def debug(self, *args, **kwargs): 1054 """Endpoint for unrecognized escape sequences. 1055 1056 By default is a noop. 1057 """ 1058 1059 1060class DiffScreen(Screen): 1061 """ 1062 A screen subclass, which maintains a set of dirty lines in its 1063 :attr:`dirty` attribute. The end user is responsible for emptying 1064 a set, when a diff is applied. 1065 1066 .. deprecated:: 0.7.0 1067 1068 The functionality contained in this class has been merged into 1069 :class:`~pyte.screens.Screen` and will be removed in 0.8.0. 1070 Please update your code accordingly. 1071 """ 1072 def __init__(self, *args, **kwargs): 1073 warnings.warn( 1074 "The functionality of ``DiffScreen` has been merged into " 1075 "``Screen`` and will be removed in 0.8.0. Please update " 1076 "your code accordingly.", DeprecationWarning) 1077 1078 super(DiffScreen, self).__init__(*args, **kwargs) 1079 1080 1081History = namedtuple("History", "top bottom ratio size position") 1082 1083 1084class HistoryScreen(Screen): 1085 """A :class:~`pyte.screens.Screen` subclass, which keeps track 1086 of screen history and allows pagination. This is not linux-specific, 1087 but still useful; see page 462 of VT520 User's Manual. 1088 1089 :param int history: total number of history lines to keep; is split 1090 between top and bottom queues. 1091 :param int ratio: defines how much lines to scroll on :meth:`next_page` 1092 and :meth:`prev_page` calls. 1093 1094 .. attribute:: history 1095 1096 A pair of history queues for top and bottom margins accordingly; 1097 here's the overall screen structure:: 1098 1099 [ 1: .......] 1100 [ 2: .......] <- top history 1101 [ 3: .......] 1102 ------------ 1103 [ 4: .......] s 1104 [ 5: .......] c 1105 [ 6: .......] r 1106 [ 7: .......] e 1107 [ 8: .......] e 1108 [ 9: .......] n 1109 ------------ 1110 [10: .......] 1111 [11: .......] <- bottom history 1112 [12: .......] 1113 1114 .. note:: 1115 1116 Don't forget to update :class:`~pyte.streams.Stream` class with 1117 appropriate escape sequences -- you can use any, since pagination 1118 protocol is not standardized, for example:: 1119 1120 Stream.escape["N"] = "next_page" 1121 Stream.escape["P"] = "prev_page" 1122 """ 1123 _wrapped = set(Stream.events) 1124 _wrapped.update(["next_page", "prev_page"]) 1125 1126 def __init__(self, columns, lines, history=100, ratio=.5): 1127 self.history = History(deque(maxlen=history), 1128 deque(maxlen=history), 1129 float(ratio), 1130 history, 1131 history) 1132 1133 super(HistoryScreen, self).__init__(columns, lines) 1134 1135 def _make_wrapper(self, event, handler): 1136 def inner(*args, **kwargs): 1137 self.before_event(event) 1138 result = handler(*args, **kwargs) 1139 self.after_event(event) 1140 return result 1141 return inner 1142 1143 def __getattribute__(self, attr): 1144 value = super(HistoryScreen, self).__getattribute__(attr) 1145 if attr in HistoryScreen._wrapped: 1146 return HistoryScreen._make_wrapper(self, attr, value) 1147 else: 1148 return value 1149 1150 def before_event(self, event): 1151 """Ensure a screen is at the bottom of the history buffer. 1152 1153 :param str event: event name, for example ``"linefeed"``. 1154 """ 1155 if event not in ["prev_page", "next_page"]: 1156 while self.history.position < self.history.size: 1157 self.next_page() 1158 1159 def after_event(self, event): 1160 """Ensure all lines on a screen have proper width (:attr:`columns`). 1161 1162 Extra characters are truncated, missing characters are filled 1163 with whitespace. 1164 1165 :param str event: event name, for example ``"linefeed"``. 1166 """ 1167 if event in ["prev_page", "next_page"]: 1168 for line in self.buffer.values(): 1169 for x in line: 1170 if x > self.columns: 1171 line.pop(x) 1172 1173 # If we're at the bottom of the history buffer and `DECTCEM` 1174 # mode is set -- show the cursor. 1175 self.cursor.hidden = not ( 1176 self.history.position == self.history.size and 1177 mo.DECTCEM in self.mode 1178 ) 1179 1180 def _reset_history(self): 1181 self.history.top.clear() 1182 self.history.bottom.clear() 1183 self.history = self.history._replace(position=self.history.size) 1184 1185 def reset(self): 1186 """Overloaded to reset screen history state: history position 1187 is reset to bottom of both queues; queues themselves are 1188 emptied. 1189 """ 1190 super(HistoryScreen, self).reset() 1191 self._reset_history() 1192 1193 def erase_in_display(self, how=0): 1194 """Overloaded to reset history state.""" 1195 super(HistoryScreen, self).erase_in_display(how) 1196 1197 if how == 3: 1198 self._reset_history() 1199 1200 def index(self): 1201 """Overloaded to update top history with the removed lines.""" 1202 top, bottom = self.margins or Margins(0, self.lines - 1) 1203 1204 if self.cursor.y == bottom: 1205 self.history.top.append(self.buffer[top]) 1206 1207 super(HistoryScreen, self).index() 1208 1209 def reverse_index(self): 1210 """Overloaded to update bottom history with the removed lines.""" 1211 top, bottom = self.margins or Margins(0, self.lines - 1) 1212 1213 if self.cursor.y == top: 1214 self.history.bottom.append(self.buffer[bottom]) 1215 1216 super(HistoryScreen, self).reverse_index() 1217 1218 def prev_page(self): 1219 """Move the screen page up through the history buffer. Page 1220 size is defined by ``history.ratio``, so for instance 1221 ``ratio = .5`` means that half the screen is restored from 1222 history on page switch. 1223 """ 1224 if self.history.position > self.lines and self.history.top: 1225 mid = min(len(self.history.top), 1226 int(math.ceil(self.lines * self.history.ratio))) 1227 1228 self.history.bottom.extendleft( 1229 self.buffer[y] 1230 for y in range(self.lines - 1, self.lines - mid - 1, -1)) 1231 self.history = self.history \ 1232 ._replace(position=self.history.position - mid) 1233 1234 for y in range(self.lines - 1, mid - 1, -1): 1235 self.buffer[y] = self.buffer[y - mid] 1236 for y in range(mid - 1, -1, -1): 1237 self.buffer[y] = self.history.top.pop() 1238 1239 self.dirty = set(range(self.lines)) 1240 1241 def next_page(self): 1242 """Move the screen page down through the history buffer.""" 1243 if self.history.position < self.history.size and self.history.bottom: 1244 mid = min(len(self.history.bottom), 1245 int(math.ceil(self.lines * self.history.ratio))) 1246 1247 self.history.top.extend(self.buffer[y] for y in range(mid)) 1248 self.history = self.history \ 1249 ._replace(position=self.history.position + mid) 1250 1251 for y in range(self.lines - mid): 1252 self.buffer[y] = self.buffer[y + mid] 1253 for y in range(self.lines - mid, self.lines): 1254 self.buffer[y] = self.history.bottom.popleft() 1255 1256 self.dirty = set(range(self.lines)) 1257 1258 1259class DebugEvent(namedtuple("Event", "name args kwargs")): 1260 """Event dispatched to :class:`~pyte.screens.DebugScreen`. 1261 1262 .. warning:: 1263 1264 This is developer API with no backward compatibility guarantees. 1265 Use at your own risk! 1266 """ 1267 @staticmethod 1268 def from_string(line): 1269 return DebugEvent(*json.loads(line)) 1270 1271 def __str__(self): 1272 return json.dumps(self) 1273 1274 def __call__(self, screen): 1275 """Execute this event on a given ``screen``.""" 1276 return getattr(screen, self.name)(*self.args, **self.kwargs) 1277 1278 1279class DebugScreen(object): 1280 r"""A screen which dumps a subset of the received events to a file. 1281 1282 >>> import io 1283 >>> with io.StringIO() as buf: 1284 ... stream = Stream(DebugScreen(to=buf)) 1285 ... stream.feed("\x1b[1;24r\x1b[4l\x1b[24;1H\x1b[0;10m") 1286 ... print(buf.getvalue()) 1287 ... 1288 ... # doctest: +NORMALIZE_WHITESPACE 1289 ["set_margins", [1, 24], {}] 1290 ["reset_mode", [4], {}] 1291 ["cursor_position", [24, 1], {}] 1292 ["select_graphic_rendition", [0, 10], {}] 1293 1294 :param file to: a file-like object to write debug information to. 1295 :param list only: a list of events you want to debug (empty by 1296 default, which means -- debug all events). 1297 1298 .. warning:: 1299 1300 This is developer API with no backward compatibility guarantees. 1301 Use at your own risk! 1302 """ 1303 def __init__(self, to=sys.stderr, only=()): 1304 self.to = to 1305 self.only = only 1306 1307 def only_wrapper(self, attr): 1308 def wrapper(*args, **kwargs): 1309 self.to.write(str(DebugEvent(attr, args, kwargs))) 1310 self.to.write(str(os.linesep)) 1311 1312 return wrapper 1313 1314 def __getattribute__(self, attr): 1315 if attr not in Stream.events: 1316 return super(DebugScreen, self).__getattribute__(attr) 1317 elif not self.only or attr in self.only: 1318 return self.only_wrapper(attr) 1319 else: 1320 return lambda *args, **kwargs: None 1321