# -*- coding: utf-8 -*- """ This module defines common screen output function. For more details, see http://asciimatics.readthedocs.io/en/latest/io.html """ from __future__ import division from __future__ import absolute_import from __future__ import print_function from __future__ import unicode_literals import os import signal import struct import sys import time from abc import ABCMeta, abstractmethod from functools import update_wrapper, partial from locale import getlocale, getdefaultlocale from logging import getLogger from math import sqrt from builtins import object from builtins import range from builtins import ord from builtins import chr from builtins import str from future.utils import with_metaclass from future.moves.itertools import zip_longest from wcwidth import wcwidth, wcswidth from asciimatics.event import KeyboardEvent, MouseEvent from asciimatics.exceptions import ResizeScreenError, StopApplication, NextScene from asciimatics.utilities import _DotDict import asciimatics.constants as constants logger = getLogger(__name__) # Looks like pywin32 is missing some Windows constants ENABLE_EXTENDED_FLAGS = 0x0080 ENABLE_QUICK_EDIT_MODE = 0x0040 class _DoubleBuffer(object): """ Pure python Screen buffering. """ def __init__(self, height, width): """ :param height: Height of the buffer to create. :param width: Width of the buffer to create. """ super(_DoubleBuffer, self).__init__() self._height = height self._width = width self._double_buffer = None line = [(u" ", Screen.COLOUR_WHITE, 0, 0, 1) for _ in range(self._width)] self._screen_buffer = [line[:] for _ in range(self._height)] self.clear(Screen.COLOUR_WHITE, 0, 0) def clear(self, fg, attr, bg, x=0, y=0, w=None, h=None): """ Clear a box in the double-buffer. This does not clear the screen buffer and so the next call to deltas will still show all changes. Default box is the whole screen buffer. :param fg: The foreground colour to use for the new buffer. :param attr: The attribute value to use for the new buffer. :param bg: The background colour to use for the new buffer. :param x: Optional X coordinate for top left of box. :param y: Optional Y coordinate for top left of box. :param w: Optional width of the box. :param h: Optional height of the box. """ width = self._width if w is None else w height = self._height if h is None else h width = max(0, min(self._width - x, width)) height = max(0, min(self._height - y, height)) line = [(u" ", fg, attr, bg, 1) for _ in range(width)] if x == 0 and y == 0 and w is None and h is None: self._double_buffer = [line[:] for _ in range(height)] else: for i in range(y, y + height): self._double_buffer[i][x:x + w] = line[:] def invalidate(self): """ Invalidate the screen buffer to force a full refresh. """ line = [(None, None, None, None, 1) for _ in range(self._width)] self._screen_buffer = [line[:] for _ in range(self._height)] def get(self, x, y): """ Get the cell value from the specified location :param x: The column (x coord) of the character. :param y: The row (y coord) of the character. :return: A 5-tuple of (unicode, foreground, attributes, background, width). """ return self._double_buffer[y][x] def set(self, x, y, value): """ Set the cell value from the specified location :param x: The column (x coord) of the character. :param y: The row (y coord) of the character. :param value: A 5-tuple of (unicode, foreground, attributes, background, width). """ self._double_buffer[y][x] = value def deltas(self, start, height): """ Return a list-like (i.e. iterable) object of (y, x) tuples """ for y in range(start, min(start + height, self._height)): for x in range(self._width): old_cell = self._screen_buffer[y][x] new_cell = self._double_buffer[y][x] if old_cell != new_cell: yield y, x def scroll(self, lines): """ Scroll the window up or down. :param lines: Number of lines to scroll. Negative numbers move the buffer up. """ line = [(u" ", Screen.COLOUR_WHITE, 0, 0, 1) for _ in range(self._width)] if lines > 0: # Limit to buffer size - this will just invalidate all the data lines = min(lines, self._height) for y in range(0, self._height - lines): self._double_buffer[y] = self._double_buffer[y + lines] self._screen_buffer[y] = self._screen_buffer[y + lines] for y in range(self._height - lines, self._height): self._double_buffer[y] = line[:] self._screen_buffer[y] = line[:] else: # Limit to buffer size - this will just invalidate all the data lines = max(lines, -self._height) for y in range(self._height - 1, -lines - 1, -1): self._double_buffer[y] = self._double_buffer[y + lines] self._screen_buffer[y] = self._screen_buffer[y + lines] for y in range(0, -lines): self._double_buffer[y] = line[:] self._screen_buffer[y] = line[:] def block_transfer(self, buffer, x, y): """ Copy a buffer entirely to this double buffer. :param buffer: The double buffer to copy :param x: The X origin for where to place it in this buffer :param y: The Y origin for where to place it in this buffer """ # Just copy the double-buffer cells - the real screen will sync on refresh. block_min_x = max(0, x) block_max_x = min(x + buffer.width, self._width) # Check for trivial non-overlap if block_min_x > block_max_x: return # Copy the available section for by in range(0, self._height): if y <= by < y + buffer.height: self._double_buffer[by][block_min_x:block_max_x] = buffer.slice( block_min_x - x, by - y, block_max_x - block_min_x) def slice(self, x, y, width): """ Provide a slice of data from the buffer at the specified location :param x: The X origin :param y: The Y origin :param width: The width of slice required :return: The slice of tuples from the current double-buffer """ return self._double_buffer[y][x:x + width] def sync(self): """ Synchronize the screen buffer with the double buffer. """ # We're copying an array of tuples, so only need to copy the 2-D array (as the tuples are immutable). # This is way faster than a deep copy (which is INCREDIBLY slow). self._screen_buffer = [row[:] for row in self._double_buffer] @property def height(self): """ The height of this buffer. """ return self._height @property def width(self): """ The width of this buffer. """ return self._width @property def plain_image(self): return ["".join(x[0] for x in self.slice(0, y, self.width)) for y in range(self.height)] @property def colour_map(self): return [[x[1:4] for x in self.slice(0, y, self.width)] for y in range(self.height)] class _AbstractCanvas(with_metaclass(ABCMeta, object)): """ Abstract class to handle screen buffering. """ # Characters for anti-aliasing line drawing. _line_chars = " ''^.|/7.\\|Ywbd#" _uni_line_chars = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█" # Colour palette for 8/16 colour terminals _8_palette = [ 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x80, 0x00, 0x80, 0x80, 0xc0, 0xc0, 0xc0, ] + [0x00 for _ in range(248 * 3)] # Colour palette for 256 colour terminals _256_palette = [ 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x80, 0x00, 0x80, 0x80, 0xc0, 0xc0, 0xc0, 0x80, 0x80, 0x80, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0xff, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x87, 0x00, 0x00, 0xaf, 0x00, 0x00, 0xd7, 0x00, 0x00, 0xff, 0x00, 0x5f, 0x00, 0x00, 0x5f, 0x5f, 0x00, 0x5f, 0x87, 0x00, 0x5f, 0xaf, 0x00, 0x5f, 0xd7, 0x00, 0x5f, 0xff, 0x00, 0x87, 0x00, 0x00, 0x87, 0x5f, 0x00, 0x87, 0x87, 0x00, 0x87, 0xaf, 0x00, 0x87, 0xd7, 0x00, 0x87, 0xff, 0x00, 0xaf, 0x00, 0x00, 0xaf, 0x5f, 0x00, 0xaf, 0x87, 0x00, 0xaf, 0xaf, 0x00, 0xaf, 0xd7, 0x00, 0xaf, 0xff, 0x00, 0xd7, 0x00, 0x00, 0xd7, 0x5f, 0x00, 0xd7, 0x87, 0x00, 0xd7, 0xaf, 0x00, 0xd7, 0xd7, 0x00, 0xd7, 0xff, 0x00, 0xff, 0x00, 0x00, 0xff, 0x5f, 0x00, 0xff, 0x87, 0x00, 0xff, 0xaf, 0x00, 0xff, 0xd7, 0x00, 0xff, 0xff, 0x5f, 0x00, 0x00, 0x5f, 0x00, 0x5f, 0x5f, 0x00, 0x87, 0x5f, 0x00, 0xaf, 0x5f, 0x00, 0xd7, 0x5f, 0x00, 0xff, 0x5f, 0x5f, 0x00, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x87, 0x5f, 0x5f, 0xaf, 0x5f, 0x5f, 0xd7, 0x5f, 0x5f, 0xff, 0x5f, 0x87, 0x00, 0x5f, 0x87, 0x5f, 0x5f, 0x87, 0x87, 0x5f, 0x87, 0xaf, 0x5f, 0x87, 0xd7, 0x5f, 0x87, 0xff, 0x5f, 0xaf, 0x00, 0x5f, 0xaf, 0x5f, 0x5f, 0xaf, 0x87, 0x5f, 0xaf, 0xaf, 0x5f, 0xaf, 0xd7, 0x5f, 0xaf, 0xff, 0x5f, 0xd7, 0x00, 0x5f, 0xd7, 0x5f, 0x5f, 0xd7, 0x87, 0x5f, 0xd7, 0xaf, 0x5f, 0xd7, 0xd7, 0x5f, 0xd7, 0xff, 0x5f, 0xff, 0x00, 0x5f, 0xff, 0x5f, 0x5f, 0xff, 0x87, 0x5f, 0xff, 0xaf, 0x5f, 0xff, 0xd7, 0x5f, 0xff, 0xff, 0x87, 0x00, 0x00, 0x87, 0x00, 0x5f, 0x87, 0x00, 0x87, 0x87, 0x00, 0xaf, 0x87, 0x00, 0xd7, 0x87, 0x00, 0xff, 0x87, 0x5f, 0x00, 0x87, 0x5f, 0x5f, 0x87, 0x5f, 0x87, 0x87, 0x5f, 0xaf, 0x87, 0x5f, 0xd7, 0x87, 0x5f, 0xff, 0x87, 0x87, 0x00, 0x87, 0x87, 0x5f, 0x87, 0x87, 0x87, 0x87, 0x87, 0xaf, 0x87, 0x87, 0xd7, 0x87, 0x87, 0xff, 0x87, 0xaf, 0x00, 0x87, 0xaf, 0x5f, 0x87, 0xaf, 0x87, 0x87, 0xaf, 0xaf, 0x87, 0xaf, 0xd7, 0x87, 0xaf, 0xff, 0x87, 0xd7, 0x00, 0x87, 0xd7, 0x5f, 0x87, 0xd7, 0x87, 0x87, 0xd7, 0xaf, 0x87, 0xd7, 0xd7, 0x87, 0xd7, 0xff, 0x87, 0xff, 0x00, 0x87, 0xff, 0x5f, 0x87, 0xff, 0x87, 0x87, 0xff, 0xaf, 0x87, 0xff, 0xd7, 0x87, 0xff, 0xff, 0xaf, 0x00, 0x00, 0xaf, 0x00, 0x5f, 0xaf, 0x00, 0x87, 0xaf, 0x00, 0xaf, 0xaf, 0x00, 0xd7, 0xaf, 0x00, 0xff, 0xaf, 0x5f, 0x00, 0xaf, 0x5f, 0x5f, 0xaf, 0x5f, 0x87, 0xaf, 0x5f, 0xaf, 0xaf, 0x5f, 0xd7, 0xaf, 0x5f, 0xff, 0xaf, 0x87, 0x00, 0xaf, 0x87, 0x5f, 0xaf, 0x87, 0x87, 0xaf, 0x87, 0xaf, 0xaf, 0x87, 0xd7, 0xaf, 0x87, 0xff, 0xaf, 0xaf, 0x00, 0xaf, 0xaf, 0x5f, 0xaf, 0xaf, 0x87, 0xaf, 0xaf, 0xaf, 0xaf, 0xaf, 0xd7, 0xaf, 0xaf, 0xff, 0xaf, 0xd7, 0x00, 0xaf, 0xd7, 0x5f, 0xaf, 0xd7, 0x87, 0xaf, 0xd7, 0xaf, 0xaf, 0xd7, 0xd7, 0xaf, 0xd7, 0xff, 0xaf, 0xff, 0x00, 0xaf, 0xff, 0x5f, 0xaf, 0xff, 0x87, 0xaf, 0xff, 0xaf, 0xaf, 0xff, 0xd7, 0xaf, 0xff, 0xff, 0xd7, 0x00, 0x00, 0xd7, 0x00, 0x5f, 0xd7, 0x00, 0x87, 0xd7, 0x00, 0xaf, 0xd7, 0x00, 0xd7, 0xd7, 0x00, 0xff, 0xd7, 0x5f, 0x00, 0xd7, 0x5f, 0x5f, 0xd7, 0x5f, 0x87, 0xd7, 0x5f, 0xaf, 0xd7, 0x5f, 0xd7, 0xd7, 0x5f, 0xff, 0xd7, 0x87, 0x00, 0xd7, 0x87, 0x5f, 0xd7, 0x87, 0x87, 0xd7, 0x87, 0xaf, 0xd7, 0x87, 0xd7, 0xd7, 0x87, 0xff, 0xd7, 0xaf, 0x00, 0xd7, 0xaf, 0x5f, 0xd7, 0xaf, 0x87, 0xd7, 0xaf, 0xaf, 0xd7, 0xaf, 0xd7, 0xd7, 0xaf, 0xff, 0xd7, 0xd7, 0x00, 0xd7, 0xd7, 0x5f, 0xd7, 0xd7, 0x87, 0xd7, 0xd7, 0xaf, 0xd7, 0xd7, 0xd7, 0xd7, 0xd7, 0xff, 0xd7, 0xff, 0x00, 0xd7, 0xff, 0x5f, 0xd7, 0xff, 0x87, 0xd7, 0xff, 0xaf, 0xd7, 0xff, 0xd7, 0xd7, 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0x00, 0x5f, 0xff, 0x00, 0x87, 0xff, 0x00, 0xaf, 0xff, 0x00, 0xd7, 0xff, 0x00, 0xff, 0xff, 0x5f, 0x00, 0xff, 0x5f, 0x5f, 0xff, 0x5f, 0x87, 0xff, 0x5f, 0xaf, 0xff, 0x5f, 0xd7, 0xff, 0x5f, 0xff, 0xff, 0x87, 0x00, 0xff, 0x87, 0x5f, 0xff, 0x87, 0x87, 0xff, 0x87, 0xaf, 0xff, 0x87, 0xd7, 0xff, 0x87, 0xff, 0xff, 0xaf, 0x00, 0xff, 0xaf, 0x5f, 0xff, 0xaf, 0x87, 0xff, 0xaf, 0xaf, 0xff, 0xaf, 0xd7, 0xff, 0xaf, 0xff, 0xff, 0xd7, 0x00, 0xff, 0xd7, 0x5f, 0xff, 0xd7, 0x87, 0xff, 0xd7, 0xaf, 0xff, 0xd7, 0xd7, 0xff, 0xd7, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0x5f, 0xff, 0xff, 0x87, 0xff, 0xff, 0xaf, 0xff, 0xff, 0xd7, 0xff, 0xff, 0xff, 0x08, 0x08, 0x08, 0x12, 0x12, 0x12, 0x1c, 0x1c, 0x1c, 0x26, 0x26, 0x26, 0x30, 0x30, 0x30, 0x3a, 0x3a, 0x3a, 0x44, 0x44, 0x44, 0x4e, 0x4e, 0x4e, 0x58, 0x58, 0x58, 0x62, 0x62, 0x62, 0x6c, 0x6c, 0x6c, 0x76, 0x76, 0x76, 0x80, 0x80, 0x80, 0x8a, 0x8a, 0x8a, 0x94, 0x94, 0x94, 0x9e, 0x9e, 0x9e, 0xa8, 0xa8, 0xa8, 0xb2, 0xb2, 0xb2, 0xbc, 0xbc, 0xbc, 0xc6, 0xc6, 0xc6, 0xd0, 0xd0, 0xd0, 0xda, 0xda, 0xda, 0xe4, 0xe4, 0xe4, 0xee, 0xee, 0xee, ] def __init__(self, height, width, buffer_height, colours, unicode_aware): """ :param height: The buffer height for this object. :param width: The buffer width for this object. :param buffer_height: The buffer height for this object. :param colours: Number of colours for this object. :param unicode_aware: Force use of unicode options for this object. """ super(_AbstractCanvas, self).__init__() # Can we handle unicode environments? self._unicode_aware = unicode_aware # Create screen buffers. self.height = height self.width = width self.colours = colours self._buffer_height = height if buffer_height is None else buffer_height self._buffer = None self._start_line = 0 self._x = 0 self._y = 0 # dictionary cache for colour blending self._blends = {} # Reset the screen ready to go... self.reset() def clear_buffer(self, fg, attr, bg, x=0, y=0, w=None, h=None): """ Clear a box in the current double-buffer used by this object. This is the recommended way to clear parts, or all, ofthe Screen without causing flicker as it will only become visible at the next refresh. Defaults to the whole buffer if no box is specified. :param fg: The foreground colour to use for the new buffer. :param attr: The attribute value to use for the new buffer. :param bg: The background colour to use for the new buffer. :param x: Optional X coordinate for top left of box. :param y: Optional Y coordinate for top left of box. :param w: Optional width of the box. :param h: Optional height of the box. """ self._buffer.clear(fg, attr, bg, x, y, w, h) def reset(self): """ Reset the internal buffers for the abstract canvas. """ # Reset our screen buffer self._start_line = 0 self._x = self._y = None self._buffer = _DoubleBuffer(self._buffer_height, self.width) self._reset() def scroll(self, lines=1): """ Scroll the abstract canvas up one line. :param lines: The number of lines to scroll. Defaults to down by one. """ self._buffer.scroll(lines) self._start_line += lines def scroll_to(self, line): """ Scroll the abstract canvas to make a specific line. :param line: The line to scroll to. """ self._buffer.scroll(line - self._start_line) self._start_line = line @abstractmethod def _reset(self): """ Internal implementation required to reset underlying drawing interface. """ @abstractmethod def refresh(self): """ Refresh this object - this will draw to the underlying display interface. """ def get_from(self, x, y): """ Get the character at the specified location. :param x: The column (x coord) of the character. :param y: The row (y coord) of the character. :return: A 4-tuple of (ascii code, foreground, attributes, background) for the character at the location. """ # Convert to buffer coordinates y -= self._start_line if y < 0 or y >= self._buffer_height or x < 0 or x >= self.width: return None cell = self._buffer.get(x, y) return ord(cell[0]), cell[1], cell[2], cell[3] def print_at(self, text, x, y, colour=7, attr=0, bg=0, transparent=False): """ Print the text at the specified location using the specified colour and attributes. :param text: The (single line) text to be printed. :param x: The column (x coord) for the start of the text. :param y: The line (y coord) for the start of the text. :param colour: The colour of the text to be displayed. :param attr: The cell attribute of the text to be displayed. :param bg: The background colour of the text to be displayed. :param transparent: Whether to print spaces or not, thus giving a transparent effect. The colours and attributes are the COLOUR_xxx and A_yyy constants defined in the Screen class. """ # Convert to the logically visible window that our double-buffer provides y -= self._start_line # Trim text to the buffer vertically. Don't trim horizontally as we don't know whether any # of these characters are dual-width yet. Handle it on the fly below... if y < 0 or y >= self._buffer_height or x > self.width: return text = str(text) if len(text) > 0: if self._unicode_aware: j = 0 for i, c in enumerate(text): # Handle under-run and overrun of double-width glyphs now. # # Note that wcwidth uses significant resources, so only call when we have a # unicode aware application. The rest of the time assume ASCII. width = wcwidth(c) if self._unicode_aware and ord(c) >= 256 else 1 if x + i + j < 0: x += (width - 1) continue if x + i + j + width > self.width: return # Now handle the update. if c != " " or not transparent: # Fix up orphaned double-width glyphs that we've just bisected. if x + i + j - 1 >= 0 and self._buffer.get(x + i + j - 1, y)[4] == 2: self._buffer.set(x + i + j - 1, y, ("x", 0, 0, 0, 1)) self._buffer.set(x + i + j, y, (c, colour, attr, bg, width)) if width == 2: j += 1 if x + i + j < self.width: self._buffer.set(x + i + j, y, (c, colour, attr, bg, 0)) # Now fix up any glyphs we may have bisected the other way. if x + i + j + 1 < self.width and self._buffer.get(x + i + j + 1, y)[4] == 0: self._buffer.set(x + i + j + 1, y, ("x", 0, 0, 0, 1)) else: # Optimized version that ignores double-width characters if x < 0: text = text[-x:] x = 0 if x + len(text) > self.width: text = text[:self.width - x] if not transparent: self._buffer.set(slice(x, x + len(text)), y, [(c, colour, attr, bg, 1) for c in text]) else: for i, c in enumerate(text): if c != " ": self._buffer.set(x + i, y, (c, colour, attr, bg, 1)) def block_transfer(self, buffer, x, y): """ Copy a buffer to the screen double buffer at a specified location. :param buffer: The double buffer to copy :param x: The X origin for where to place it in the Screen :param y: The Y origin for where to place it in the Screen """ self._buffer.block_transfer(buffer, x, y) @property def start_line(self): """ :return: The start line of the top of the canvas. """ return self._start_line @property def unicode_aware(self): """ :return: Whether unicode input/output is supported or not. """ return self._unicode_aware @property def dimensions(self): """ :return: The full dimensions of the canvas as a (height, width) tuple. """ return self.height, self.width @property def palette(self): """ :return: A palette compatible with the PIL. """ if self.colours < 256: # Use the ANSI colour set. return self._8_palette else: return self._256_palette def centre(self, text, y, colour=7, attr=0, colour_map=None): """ Centre the text on the specified line (y) using the optional colour and attributes. :param text: The (single line) text to be printed. :param y: The line (y coord) for the start of the text. :param colour: The colour of the text to be displayed. :param attr: The cell attribute of the text to be displayed. :param colour_map: Colour/attribute list for multi-colour text. The colours and attributes are the COLOUR_xxx and A_yyy constants defined in the Screen class. """ if self._unicode_aware: x = (self.width - wcswidth(text)) // 2 else: x = (self.width - len(text)) // 2 self.paint(text, x, y, colour, attr, colour_map=colour_map) def paint(self, text, x, y, colour=7, attr=0, bg=0, transparent=False, colour_map=None): """ Paint multi-colour text at the defined location. :param text: The (single line) text to be printed. :param x: The column (x coord) for the start of the text. :param y: The line (y coord) for the start of the text. :param colour: The default colour of the text to be displayed. :param attr: The default cell attribute of the text to be displayed. :param bg: The default background colour of the text to be displayed. :param transparent: Whether to print spaces or not, thus giving a transparent effect. :param colour_map: Colour/attribute list for multi-colour text. The colours and attributes are the COLOUR_xxx and A_yyy constants defined in the Screen class. colour_map is a list of tuples (foreground, attribute, background) that must be the same length as the passed in text (or None if no mapping is required). """ if colour_map is None: self.print_at(text, x, y, colour, attr, bg, transparent) else: offset = next_offset = 0 current = "" for c, m in zip_longest(str(text), colour_map): if m: if len(current) > 0: self.print_at(current, x + offset, y, colour, attr, bg, transparent) offset = next_offset current = "" if len(m) > 0 and m[0] is not None: colour = m[0] if len(m) > 1 and m[1] is not None: attr = m[1] if len(m) > 2 and m[2] is not None: bg = m[2] if c: current += c next_offset += wcwidth(c) if ord(c) >= 256 else 1 if len(current) > 0: self.print_at(current, x + offset, y, colour, attr, bg, transparent) def _blend(self, new, old, ratio): """ Blend the new colour with the old according to the ratio. :param new: The new colour (or None if not required). :param old: The old colour. :param ratio: The ratio to blend new and old :returns: the new colour index to use for the required blend. """ # Don't bother blending if none is required. if new is None: return old # Check colour blend cache for a quick answer. key = (min(new, old), max(new, old)) if key in self._blends: return self._blends[key] # No quick answer - do it the long way... First lookup the RGB values # for both colours and blend. (r1, g1, b1) = self.palette[new * 3:new * 3 + 3] (r2, g2, b2) = self.palette[old * 3:old * 3 + 3] # Helper function to blend RGB values. def f(c1, c2): return ((c1 * ratio) + (c2 * (100 - ratio))) // 100 r = f(r1, r2) g = f(g1, g2) b = f(b1, b2) # Now do the reverse lookup... nearest = (256 ** 2) * 3 match = 0 for c in range(self.colours): (rc, gc, bc) = self.palette[c * 3:c * 3 + 3] diff = sqrt(((rc - r) * 0.3) ** 2 + ((gc - g) * 0.59) ** 2 + ((bc - b) * 0.11) ** 2) if diff < nearest: nearest = diff match = c # Save off the answer and return it self._blends[key] = match return match def highlight(self, x, y, w, h, fg=None, bg=None, blend=100): """ Highlight a specified section of the screen. :param x: The column (x coord) for the start of the highlight. :param y: The line (y coord) for the start of the highlight. :param w: The width of the highlight (in characters). :param h: The height of the highlight (in characters). :param fg: The foreground colour of the highlight. :param bg: The background colour of the highlight. :param blend: How much (as a percentage) to take of the new colour when blending. The colours and attributes are the COLOUR_xxx and A_yyy constants defined in the Screen class. If fg or bg are None that means don't change the foreground/background as appropriate. """ # Convert to buffer coordinates y -= self._start_line for i in range(w): if x + i >= self.width or x + i < 0: continue for j in range(h): if y + j >= self._buffer_height or y + j < 0: continue old = self._buffer.get(x + i, y + j) new_bg = self._blend(bg, old[3], blend) new_fg = self._blend(fg, old[1], blend) self._buffer.set(x + i, y + j, (old[0], new_fg, old[2], new_bg, old[4])) def is_visible(self, x, y): """ Return whether the specified location is on the visible screen. :param x: The column (x coord) for the location to check. :param y: The line (y coord) for the location to check. """ return ((x >= 0) and (x <= self.width) and (y >= self._start_line) and (y < self._start_line + self.height)) def move(self, x, y): """ Move the drawing cursor to the specified position. :param x: The column (x coord) for the location to check. :param y: The line (y coord) for the location to check. """ self._x = int(round(x * 2, 0)) self._y = int(round(y * 2, 0)) def draw(self, x, y, char=None, colour=7, bg=0, thin=False): """ Draw a line from drawing cursor to the specified position. This uses a modified Bressenham algorithm, interpolating twice as many points to render down to anti-aliased characters when no character is specified, or uses standard algorithm plotting with the specified character. :param x: The column (x coord) for the location to check. :param y: The line (y coord) for the location to check. :param char: Optional character to use to draw the line. :param colour: Optional colour for plotting the line. :param bg: Optional background colour for plotting the line. :param thin: Optional width of anti-aliased line. """ # Decide what type of line drawing to use. line_chars = (self._uni_line_chars if self._unicode_aware else self._line_chars) # Define line end points. x0 = self._x y0 = self._y x1 = int(round(x * 2, 0)) y1 = int(round(y * 2, 0)) # Remember last point for next line. self._x = x1 self._y = y1 # Don't bother drawing anything if we're guaranteed to be off-screen if ((x0 < 0 and x1 < 0) or (x0 >= self.width * 2 and x1 >= self.width * 2) or (y0 < 0 and y1 < 0) or (y0 >= self.height * 2 and y1 >= self.height * 2)): return dx = abs(x1 - x0) dy = abs(y1 - y0) sx = -1 if x0 > x1 else 1 sy = -1 if y0 > y1 else 1 def _get_start_char(cx, cy): needle = self.get_from(cx, cy) if needle is not None: letter, cfg, _, cbg = needle if colour == cfg and bg == cbg and chr(letter) in line_chars: return line_chars.find(chr(letter)) return 0 def _fast_fill(start_x, end_x, iy): next_char = -1 for ix in range(start_x, end_x): if ix % 2 == 0 or next_char == -1: next_char = _get_start_char(ix // 2, iy // 2) next_char |= 2 ** abs(ix % 2) * 4 ** (iy % 2) if ix % 2 == 1: self.print_at(line_chars[next_char], ix // 2, iy // 2, colour, bg=bg) if end_x % 2 == 1: self.print_at(line_chars[next_char], end_x // 2, iy // 2, colour, bg=bg) def _draw_on_x(ix, iy): err = dx px = ix - 2 py = iy - 2 next_char = 0 while ix != x1: if ix < px or ix - px >= 2 or iy < py or iy - py >= 2: px = ix & ~1 py = iy & ~1 next_char = _get_start_char(px // 2, py // 2) next_char |= 2 ** abs(ix % 2) * 4 ** (iy % 2) err -= 2 * dy if err < 0: iy += sy err += 2 * dx ix += sx if char is None: self.print_at(line_chars[next_char], px // 2, py // 2, colour, bg=bg) else: self.print_at(char, px // 2, py // 2, colour, bg=bg) def _draw_on_y(ix, iy): err = dy px = ix - 2 py = iy - 2 next_char = 0 while iy != y1: if ix < px or ix - px >= 2 or iy < py or iy - py >= 2: px = ix & ~1 py = iy & ~1 next_char = _get_start_char(px // 2, py // 2) next_char |= 2 ** abs(ix % 2) * 4 ** (iy % 2) err -= 2 * dx if err < 0: ix += sx err += 2 * dy iy += sy if char is None: self.print_at(line_chars[next_char], px // 2, py // 2, colour, bg=bg) else: self.print_at(char, px // 2, py // 2, colour, bg=bg) if dy == 0 and thin and char is None: # Fast-path for polygon filling _fast_fill(min(x0, x1), max(x0, x1), y0) elif dx > dy: _draw_on_x(x0, y0) if not thin: _draw_on_x(x0, y0 + 1) else: _draw_on_y(x0, y0) if not thin: _draw_on_y(x0 + 1, y0) def fill_polygon(self, polygons, colour=7, bg=0): """ Draw a filled polygon. This function uses the scan line algorithm to create the polygon. See https://www.cs.uic.edu/~jbell/CourseNotes/ComputerGraphics/PolygonFilling.html for details. :param polygons: A list of polygons (which are each a list of (x,y) coordinates for the points of the polygon) - i.e. nested list of 2-tuples. :param colour: The foreground colour to use for the polygon :param bg: The background colour to use for the polygon """ def _add_edge(a, b): # Ignore horizontal lines - they are redundant if a[1] == b[1]: return # Ignore any edges that do not intersect the visible raster lines at all. if (a[1] < 0 and b[1] < 0) or (a[1] >= self.height and b[1] >= self.height): return # Save off the edge, always starting at the lowest value of y. new_edge = _DotDict() if a[1] < b[1]: new_edge.min_y = a[1] new_edge.max_y = b[1] new_edge.x = a[0] new_edge.dx = (b[0] - a[0]) / (b[1] - a[1]) / 2 else: new_edge.min_y = b[1] new_edge.max_y = a[1] new_edge.x = b[0] new_edge.dx = (a[0] - b[0]) / (a[1] - b[1]) / 2 edges.append(new_edge) # Create a table of all the edges in the polygon, sorted on smallest x. logger.debug("Processing polygon: %s", polygons) min_y = self.height max_y = -1 edges = [] last = None for polygon in polygons: # Ignore lines and polygons. if len(polygon) <= 2: continue # Ignore any polygons completely off the screen x, y = zip(*polygon) p_min_x = min(x) p_max_x = max(x) p_min_y = min(y) p_max_y = max(y) if p_max_x < 0 or p_min_x >= self.width or p_max_y < 0 or p_min_y > self.height: continue # Build up the edge list, maintaining bounding coordinates on the Y axis. min_y = min(p_min_y, min_y) max_y = max(p_max_y, max_y) for i, point in enumerate(polygon): if i != 0: _add_edge(last, point) last = point _add_edge(polygon[0], polygon[-1]) edges = sorted(edges, key=lambda e: e.x) # Check we still have something to do: if len(edges) == 0: return # Re-base all edges to visible Y coordinates of the screen. for edge in edges: if edge.min_y < 0: edge.x -= int(edge.min_y * 2) * edge.dx edge.min_y = 0 min_y = max(0, min_y) max_y = min(max_y - min_y, self.height) logger.debug("Resulting edges: %s", edges) # Render each line in the bounding rectangle. for y in [min_y + (i / 2) for i in range(0, int(max_y) * 2)]: # Create a list of live edges (for drawing this raster line) and edges for next # iteration of the raster. live_edges = [] new_edges = [] for edge in edges: if edge.min_y <= y <= edge.max_y: live_edges.append(edge) if y < edge.max_y: new_edges.append(edge) # Draw the portions of the line that are inside the polygon. count = 0 last_x = 0 for edge in live_edges: # Draw the next segment if 0 <= y < self.height: if edge.max_y != y: count += 1 if count % 2 == 1: last_x = edge.x else: # Don't bother drawing lines entirely off the screen. if not ((last_x < 0 and edge.x < 0) or (last_x >= self.width and edge.x >= self.width)): # Clip raster to screen width. self.move(max(0, last_x), y) self.draw( min(edge.x, self.width), y, colour=colour, bg=bg, thin=True) # Update the x location for this active edge. edge.x += edge.dx # Rely on the fact that we have the same dicts in both live_edges and new_edges, so # we just need to resort new_edges for the next iteration. edges = sorted(new_edges, key=lambda e: e.x) class TemporaryCanvas(_AbstractCanvas): """ A TemporaryCanvas is an object that can only be used to draw to a buffer. This class is desigend purely for use by dynamic renderers and so ignores some features of a full Canvas - most notably the screen related fhnction (e.g. the screen buffer and related properties). """ def __init__(self, height, width): """ :param height: The height of the screen buffer to be used. :param width: The width of the screen buffer to be used. """ # Colours and unicode rendering are up to the user. Pick defaults that won't limit them. super(TemporaryCanvas, self).__init__(height, width, None, 256, True) @property def plain_image(self): return self._buffer.plain_image @property def colour_map(self): return self._buffer.colour_map def refresh(self): pass def _reset(self): pass class Canvas(_AbstractCanvas): """ A Canvas is an object that can be used to draw to the screen. It maintains its own buffer that will be flushed to the screen when `refresh()` is called. """ def __init__(self, screen, height, width, x=None, y=None): """ :param screen: The underlying Screen that will be drawn to on refresh. :param height: The height of the screen buffer to be used. :param width: The width of the screen buffer to be used. :param x: The x position for the top left corner of the Canvas. :param y: The y position for the top left corner of the Canvas. If either of the x or y positions is not set, the Canvas will default to centring within the current Screen for that location. """ # Save off the screen details. super(Canvas, self).__init__( height, width, None, screen.colours, screen.unicode_aware) self._screen = screen self._dx = (screen.width - width) // 2 if x is None else x self._dy = (screen.height - height) // 2 if y is None else y def refresh(self): """ Flush the canvas content to the underlying screen. """ self._screen.block_transfer(self._buffer, self._dx, self._dy) def _reset(self): # Nothing needed for a Canvas pass @property def origin(self): """ The location of top left corner of the canvas on the Screen. :returns: A tuple (x, y) of the location """ return self._dx, self._dy class Screen(with_metaclass(ABCMeta, _AbstractCanvas)): """ Class to track basic state of the screen. This constructs the necessary resources to allow us to do the ASCII animations. This is an abstract class that will build the correct concrete class for you when you call :py:meth:`.wrapper`. If needed, you can use the :py:meth:`~.Screen.open` and :py:meth:`~.Screen.close` methods for finer grained control of the construction and tidy up. Note that you need to define the required height for your screen buffer. This is important if you plan on using any Effects that will scroll the screen vertically (e.g. Scroll). It must be big enough to handle the full scrolling of your selected Effect. """ # Text attributes for use when printing to the Screen. A_BOLD = constants.A_BOLD A_NORMAL = constants.A_NORMAL A_REVERSE = constants.A_REVERSE A_UNDERLINE = constants.A_UNDERLINE # Text colours for use when printing to the Screen. COLOUR_DEFAULT = constants.COLOUR_DEFAULT COLOUR_BLACK = constants.COLOUR_BLACK COLOUR_RED = constants.COLOUR_RED COLOUR_GREEN = constants.COLOUR_GREEN COLOUR_YELLOW = constants.COLOUR_YELLOW COLOUR_BLUE = constants.COLOUR_BLUE COLOUR_MAGENTA = constants.COLOUR_MAGENTA COLOUR_CYAN = constants.COLOUR_CYAN COLOUR_WHITE = constants.COLOUR_WHITE # Standard extended key codes. KEY_ESCAPE = -1 KEY_F1 = -2 KEY_F2 = -3 KEY_F3 = -4 KEY_F4 = -5 KEY_F5 = -6 KEY_F6 = -7 KEY_F7 = -8 KEY_F8 = -9 KEY_F9 = -10 KEY_F10 = -11 KEY_F11 = -12 KEY_F12 = -13 KEY_F13 = -14 KEY_F14 = -15 KEY_F15 = -16 KEY_F16 = -17 KEY_F17 = -18 KEY_F18 = -19 KEY_F19 = -20 KEY_F20 = -21 KEY_F21 = -22 KEY_F22 = -23 KEY_F23 = -24 KEY_F24 = -25 KEY_PRINT_SCREEN = -100 KEY_INSERT = -101 KEY_DELETE = -102 KEY_HOME = -200 KEY_END = -201 KEY_LEFT = -203 KEY_UP = -204 KEY_RIGHT = -205 KEY_DOWN = -206 KEY_PAGE_UP = -207 KEY_PAGE_DOWN = -208 KEY_BACK = -300 KEY_TAB = -301 KEY_BACK_TAB = -302 KEY_NUMPAD0 = -400 KEY_NUMPAD1 = -401 KEY_NUMPAD2 = -402 KEY_NUMPAD3 = -403 KEY_NUMPAD4 = -404 KEY_NUMPAD5 = -405 KEY_NUMPAD6 = -406 KEY_NUMPAD7 = -407 KEY_NUMPAD8 = -408 KEY_NUMPAD9 = -409 KEY_MULTIPLY = -410 KEY_ADD = -411 KEY_SUBTRACT = -412 KEY_DECIMAL = -413 KEY_DIVIDE = -414 KEY_CAPS_LOCK = -500 KEY_NUM_LOCK = -501 KEY_SCROLL_LOCK = -502 KEY_SHIFT = -600 KEY_CONTROL = -601 KEY_MENU = -602 def __init__(self, height, width, buffer_height, unicode_aware): """ Don't call this constructor directly. """ super(Screen, self).__init__( height, width, buffer_height, 0, unicode_aware) # Initialize base class variables - e.g. those used for drawing. self.height = height self.width = width self._last_start_line = 0 # Set up internal state for colours - used by children to determine # changes to text colour when refreshing the screen. self._colour = 0 self._attr = 0 self._bg = 0 # tracking of current cursor position - used in screen refresh. self._cur_x = 0 self._cur_y = 0 # Control variables for playing out a set of Scenes. self._scenes = [] self._scene_index = 0 self._frame = 0 self._idle_frame_count = 0 self._forced_update = False self._unhandled_input = self._unhandled_event_default @classmethod def open(cls, height=None, catch_interrupt=False, unicode_aware=None): """ Construct a new Screen for any platform. This will just create the correct Screen object for your environment. See :py:meth:`.wrapper` for a function to create and tidy up once you've finished with the Screen. :param height: The buffer height for this window (for testing only). :param catch_interrupt: Whether to catch and prevent keyboard interrupts. Defaults to False to maintain backwards compatibility. :param unicode_aware: Whether the application can use unicode or not. If None, try to detect from the environment if UTF-8 is enabled. """ if sys.platform == "win32": # Clone the standard output buffer so that we can do whatever we # need for the application, but restore the buffer at the end. # Note that we need to resize the clone to ensure that it is the # same size as the original in some versions of Windows. old_out = win32console.PyConsoleScreenBufferType( win32file.CreateFile("CONOUT$", win32file.GENERIC_READ | win32file.GENERIC_WRITE, win32file.FILE_SHARE_WRITE, None, win32file.OPEN_ALWAYS, 0, None)) try: info = old_out.GetConsoleScreenBufferInfo() except pywintypes.error: info = None win_out = win32console.CreateConsoleScreenBuffer() if info: win_out.SetConsoleScreenBufferSize(info['Size']) else: win_out.SetStdHandle(win32console.STD_OUTPUT_HANDLE) win_out.SetConsoleActiveScreenBuffer() # Get the standard input buffer. win_in = win32console.PyConsoleScreenBufferType( win32file.CreateFile("CONIN$", win32file.GENERIC_READ | win32file.GENERIC_WRITE, win32file.FILE_SHARE_READ, None, win32file.OPEN_ALWAYS, 0, None)) win_in.SetStdHandle(win32console.STD_INPUT_HANDLE) # Hide the cursor. win_out.SetConsoleCursorInfo(1, 0) # Disable scrolling out_mode = win_out.GetConsoleMode() win_out.SetConsoleMode( out_mode & ~ win32console.ENABLE_WRAP_AT_EOL_OUTPUT) # Enable mouse input, disable quick-edit mode and disable ctrl-c # if needed. in_mode = win_in.GetConsoleMode() new_mode = (in_mode | win32console.ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS) new_mode &= ~ENABLE_QUICK_EDIT_MODE if catch_interrupt: # Ignore ctrl-c handlers if specified. new_mode &= ~win32console.ENABLE_PROCESSED_INPUT win_in.SetConsoleMode(new_mode) screen = _WindowsScreen(win_out, win_in, height, old_out, in_mode, unicode_aware=unicode_aware) else: # Reproduce curses.wrapper() stdscr = curses.initscr() curses.noecho() curses.cbreak() stdscr.keypad(1) # Fed up with linters complaining about original curses code - trying to be a bit better... # noinspection PyBroadException # pylint: disable=broad-except try: curses.start_color() except Exception as e: logger.debug(e) screen = _CursesScreen(stdscr, height, catch_interrupt=catch_interrupt, unicode_aware=unicode_aware) return screen @abstractmethod def close(self, restore=True): """ Close down this Screen and tidy up the environment as required. :param restore: whether to restore the environment or not. """ @classmethod def wrapper(cls, func, height=None, catch_interrupt=False, arguments=None, unicode_aware=None): """ Construct a new Screen for any platform. This will initialize the Screen, call the specified function and then tidy up the system as required when the function exits. :param func: The function to call once the Screen has been created. :param height: The buffer height for this Screen (only for test purposes). :param catch_interrupt: Whether to catch and prevent keyboard interrupts. Defaults to False to maintain backwards compatibility. :param arguments: Optional arguments list to pass to func (after the Screen object). :param unicode_aware: Whether the application can use unicode or not. If None, try to detect from the environment if UTF-8 is enabled. """ screen = Screen.open(height, catch_interrupt=catch_interrupt, unicode_aware=unicode_aware) restore = True try: try: if arguments: return func(screen, *arguments) else: return func(screen) except ResizeScreenError: restore = False raise finally: screen.close(restore) def _reset(self): """ Reset the Screen. """ self._last_start_line = 0 self._colour = None self._attr = None self._bg = None self._cur_x = None self._cur_y = None def refresh(self): """ Refresh the screen. """ # Scroll the screen now - we've already sorted the double-buffer to reflect this change. if self._last_start_line != self._start_line: self._scroll(self._start_line - self._last_start_line) self._last_start_line = self._start_line # Now draw any deltas to the scrolled screen. Note that CJK character sets sometimes # use double-width characters, so don't try to draw the next 2nd char (of 0 width). for y, x in self._buffer.deltas(0, self.height): new_cell = self._buffer.get(x, y) if new_cell[4] > 0: self._change_colours(new_cell[1], new_cell[2], new_cell[3]) self._print_at(new_cell[0], x, y, new_cell[4]) # Resynch for next refresh. self._buffer.sync() def clear(self): """ Clear the Screen of all content. Note that this will instantly clear the Screen and reset all buffers to the default state, without waiting for you to call :py:meth:`~.Screen.refresh`. It is designed for use once at the start of your application to reset all buffers and the screen to a known state. If you want to clear parts, or all, of the Screen inside your application without any flicker, use :py:meth:`~.Screen.clear_buffer` instead. """ # Clear the actual terminal self.reset() self._change_colours(Screen.COLOUR_WHITE, 0, 0) self._clear() def get_key(self): """ Check for a key without waiting. This method is deprecated. Use :py:meth:`.get_event` instead. """ event = self.get_event() if event and isinstance(event, KeyboardEvent): return event.key_code return None @abstractmethod def get_event(self): """ Check for any events (e.g. key-press or mouse movement) without waiting. :returns: A :py:obj:`.Event` object if anything was detected, otherwise it returns None. """ @staticmethod def ctrl(char): """ Calculate the control code for a given key. For example, this converts "a" to 1 (which is the code for ctrl-a). :param char: The key to convert to a control code. :return: The control code as an integer or None if unknown. """ # Convert string to int... assuming any non-integer is a string. # TODO: Consider asserting a more rigorous test without falling back to past basestring. if not isinstance(char, int): char = ord(char.upper()) # Only deal with the characters between '@' and '_' return char & 0x1f if 64 <= char <= 95 else None @abstractmethod def has_resized(self): """ Check whether the screen has been re-sized. :returns: True when the screen has been re-sized since the last check. """ def getch(self, x, y): """ Get the character at a specified location. This method is deprecated. Use :py:meth:`.get_from` instead. :param x: The x coordinate. :param y: The y coordinate. """ return self.get_from(x, y) def putch(self, text, x, y, colour=7, attr=0, bg=0, transparent=False): """ Print text at the specified location. This method is deprecated. Use :py:meth:`.print_at` instead. :param text: The (single line) text to be printed. :param x: The column (x coord) for the start of the text. :param y: The line (y coord) for the start of the text. :param colour: The colour of the text to be displayed. :param attr: The cell attribute of the text to be displayed. :param bg: The background colour of the text to be displayed. :param transparent: Whether to print spaces or not, thus giving a transparent effect. """ self.print_at(text, x, y, colour, attr, bg, transparent) @staticmethod def _unhandled_event_default(event): """ Default unhandled event handler for handling simple scene navigation. """ if isinstance(event, KeyboardEvent): c = event.key_code if c in (ord("X"), ord("x"), ord("Q"), ord("q")): raise StopApplication("User terminated app") if c in (ord(" "), ord("\n"), ord("\r")): raise NextScene() def play(self, scenes, stop_on_resize=False, unhandled_input=None, start_scene=None, repeat=True, allow_int=False): """ Play a set of scenes. This is effectively a helper function to wrap :py:meth:`.set_scenes` and :py:meth:`.draw_next_frame` to simplify animation for most applications. :param scenes: a list of :py:obj:`.Scene` objects to play. :param stop_on_resize: Whether to stop when the screen is resized. Default is to carry on regardless - which will typically result in an error. This is largely done for back-compatibility. :param unhandled_input: Function to call for any input not handled by the Scenes/Effects being played. Defaults to a function that closes the application on "Q" or "X" being pressed. :param start_scene: The old Scene to start from. This must have name that matches the name of one of the Scenes passed in. :param repeat: Whether to repeat the Scenes once it has reached the end. Defaults to True. :param allow_int: Allow input to interrupt frame rate delay. :raises ResizeScreenError: if the screen is resized (and allowed by stop_on_resize). The unhandled input function just takes one parameter - the input event that was not handled. """ # Initialise the Screen for animation. self.set_scenes( scenes, unhandled_input=unhandled_input, start_scene=start_scene) # Mainline loop for animations try: while True: a = time.time() self.draw_next_frame(repeat=repeat) if self.has_resized(): if stop_on_resize: self._scenes[self._scene_index].exit() raise ResizeScreenError("Screen resized", self._scenes[self._scene_index]) b = time.time() if b - a < 0.05: # Just in case time has jumped (e.g. time change), ensure we only delay for 0.05s pause = min(0.05, a + 0.05 - b) if allow_int: self.wait_for_input(pause) else: time.sleep(pause) except StopApplication: # Time to stop - just exit the function. return def set_scenes(self, scenes, unhandled_input=None, start_scene=None): """ Remember a set of scenes to be played. This must be called before using :py:meth:`.draw_next_frame`. :param scenes: a list of :py:obj:`.Scene` objects to play. :param unhandled_input: Function to call for any input not handled by the Scenes/Effects being played. Defaults to a function that closes the application on "Q" or "X" being pressed. :param start_scene: The old Scene to start from. This must have name that matches the name of one of the Scenes passed in. :raises ResizeScreenError: if the screen is resized (and allowed by stop_on_resize). The unhandled input function just takes one parameter - the input event that was not handled. """ # Save off the scenes now. self._scenes = scenes # Set up default unhandled input handler if needed. if unhandled_input is None: # Check that none of the Effects is incompatible with the default # handler. safe = True for scene in self._scenes: for effect in scene.effects: safe &= effect.safe_to_default_unhandled_input if safe: unhandled_input = self._unhandled_event_default self._unhandled_input = unhandled_input # Find the starting scene. Default to first if no match. self._scene_index = 0 if start_scene is not None: for i, scene in enumerate(scenes): if scene.name == start_scene.name: self._scene_index = i break # Reset the Scene - this allows the original scene to pick up old # values on resizing. self._scenes[self._scene_index].reset( old_scene=start_scene, screen=self) # Reset other internal state for the animation self._frame = 0 self._idle_frame_count = 0 self._forced_update = False self.clear() def draw_next_frame(self, repeat=True): """ Draw the next frame in the currently configured Scenes. You must call :py:meth:`.set_scenes` before using this for the first time. :param repeat: Whether to repeat the Scenes once it has reached the end. Defaults to True. :raises StopApplication: if the application should be terminated. """ scene = self._scenes[self._scene_index] try: # Check for an event now and remember for refresh reasons. event = self.get_event() got_event = event is not None # Now process all the input events while event is not None: event = scene.process_event(event) if event is not None and self._unhandled_input is not None: self._unhandled_input(event) event = self.get_event() # Only bother with a refresh if there was an event to process or # we have to refresh due to the refresh limit required for an # Effect. self._frame += 1 self._idle_frame_count -= 1 if got_event or self._idle_frame_count <= 0 or self._forced_update: self._forced_update = False self._idle_frame_count = 1000000 for effect in scene.effects: # Update the effect and delete if needed. effect.update(self._frame) if effect.delete_count is not None: effect.delete_count -= 1 if effect.delete_count <= 0: scene.remove_effect(effect) # Sort out when we next _need_ to do a refresh. if effect.frame_update_count > 0: self._idle_frame_count = min(self._idle_frame_count, effect.frame_update_count) self.refresh() if 0 < scene.duration <= self._frame: raise NextScene() except NextScene as e: # Tidy up the current scene. scene.exit() # Find the specified next Scene if e.name is None: # Just allow next iteration of loop self._scene_index += 1 if self._scene_index >= len(self._scenes): if repeat: self._scene_index = 0 else: raise StopApplication("Repeat disabled") else: # Find the required scene. for i, scene in enumerate(self._scenes): if scene.name == e.name: self._scene_index = i break else: raise RuntimeError( "Could not find Scene: '{}'".format(e.name)) # Reset the screen if needed. scene = self._scenes[self._scene_index] scene.reset() self._frame = 0 self._idle_frame_count = 0 if scene.clear: self.clear() @property def current_scene(self): """ :return: The scene currently being rendered. To be used in conjunction with :py:meth:`.draw_next_frame`. """ return self._scenes[self._scene_index] def force_update(self, full_refresh=False): """ Force the Screen to redraw the current Scene on the next call to draw_next_frame, overriding the frame_update_count value for all the Effects. :param full_refresh: if True force the whole screen to redraw. """ self._forced_update = True if full_refresh: self._buffer.invalidate() @abstractmethod def _change_colours(self, colour, attr, bg): """ Change current colour if required. :param colour: New colour to use. :param attr: New attributes to use. :param bg: New background colour to use. """ @abstractmethod def wait_for_input(self, timeout): """ Wait until there is some input or the timeout is hit. :param timeout: Time to wait for input in seconds (floating point). """ @abstractmethod def _print_at(self, text, x, y, width): """ Print string at the required location. :param text: The text string to print. :param x: The x coordinate :param y: The Y coordinate :param width: The width of the character (for dual-width glyphs in CJK languages). """ @abstractmethod def _clear(self): """ Clear the window. """ @abstractmethod def _scroll(self, lines): """ Scroll the window up or down. :param lines: Number of lines to scroll. Negative numbers scroll down. """ @abstractmethod def set_title(self, title): """ Set the title for this terminal/console session. This will typically change the text displayed in the window title bar. :param title: The title to be set. """ class ManagedScreen(): """ Decorator and class to create a managed Screen. It can be used in two ways. If used as a method decorator it will create and open a new Screen, pass the screen to the method as a keyword argument, and close the screen when the method has completed. If used with the with statement the class will create and open a new Screen, return the screen for using in the block, and close the screen when the statement ends. Note that any arguments are in this class so that you can use it as a decorator or using the with statment. No arguments are required to use. """ def __init__(self, func=lambda: None): """ :param func: The function to call once the Screen has been created. """ update_wrapper(self, func) self.func = func self.screen = None def __get__(self, obj, objtype): """ Class decorator method, so we can use the class in a with statement. See https://stackoverflow.com/a/3296318/4994021 for details. """ return partial(self.__call__, obj) def __call__(self, *args, **kwargs): screen = Screen.open() kwargs["screen"] = screen output = self.func(*args, **kwargs) screen.close() return output def __enter__(self): """ Method used for with statement """ self.screen = Screen.open() return self.screen def __exit__(self, type, value, traceback): """ Method used for with statement """ self.screen.close() if sys.platform == "win32": import win32con import win32console import win32event import win32file import pywintypes class _WindowsScreen(Screen): """ Windows screen implementation. """ # Virtual key code mapping. _KEY_MAP = { win32con.VK_ESCAPE: Screen.KEY_ESCAPE, win32con.VK_F1: Screen.KEY_F1, win32con.VK_F2: Screen.KEY_F2, win32con.VK_F3: Screen.KEY_F3, win32con.VK_F4: Screen.KEY_F4, win32con.VK_F5: Screen.KEY_F5, win32con.VK_F6: Screen.KEY_F6, win32con.VK_F7: Screen.KEY_F7, win32con.VK_F8: Screen.KEY_F8, win32con.VK_F9: Screen.KEY_F9, win32con.VK_F10: Screen.KEY_F10, win32con.VK_F11: Screen.KEY_F11, win32con.VK_F12: Screen.KEY_F12, win32con.VK_F13: Screen.KEY_F13, win32con.VK_F14: Screen.KEY_F14, win32con.VK_F15: Screen.KEY_F15, win32con.VK_F16: Screen.KEY_F16, win32con.VK_F17: Screen.KEY_F17, win32con.VK_F18: Screen.KEY_F18, win32con.VK_F19: Screen.KEY_F19, win32con.VK_F20: Screen.KEY_F20, win32con.VK_F21: Screen.KEY_F21, win32con.VK_F22: Screen.KEY_F22, win32con.VK_F23: Screen.KEY_F23, win32con.VK_F24: Screen.KEY_F24, win32con.VK_PRINT: Screen.KEY_PRINT_SCREEN, win32con.VK_INSERT: Screen.KEY_INSERT, win32con.VK_DELETE: Screen.KEY_DELETE, win32con.VK_HOME: Screen.KEY_HOME, win32con.VK_END: Screen.KEY_END, win32con.VK_LEFT: Screen.KEY_LEFT, win32con.VK_UP: Screen.KEY_UP, win32con.VK_RIGHT: Screen.KEY_RIGHT, win32con.VK_DOWN: Screen.KEY_DOWN, win32con.VK_PRIOR: Screen.KEY_PAGE_UP, win32con.VK_NEXT: Screen.KEY_PAGE_DOWN, win32con.VK_BACK: Screen.KEY_BACK, win32con.VK_TAB: Screen.KEY_TAB } _EXTRA_KEY_MAP = { win32con.VK_NUMPAD0: Screen.KEY_NUMPAD0, win32con.VK_NUMPAD1: Screen.KEY_NUMPAD1, win32con.VK_NUMPAD2: Screen.KEY_NUMPAD2, win32con.VK_NUMPAD3: Screen.KEY_NUMPAD3, win32con.VK_NUMPAD4: Screen.KEY_NUMPAD4, win32con.VK_NUMPAD5: Screen.KEY_NUMPAD5, win32con.VK_NUMPAD6: Screen.KEY_NUMPAD6, win32con.VK_NUMPAD7: Screen.KEY_NUMPAD7, win32con.VK_NUMPAD8: Screen.KEY_NUMPAD8, win32con.VK_NUMPAD9: Screen.KEY_NUMPAD9, win32con.VK_MULTIPLY: Screen.KEY_MULTIPLY, win32con.VK_ADD: Screen.KEY_ADD, win32con.VK_SUBTRACT: Screen.KEY_SUBTRACT, win32con.VK_DECIMAL: Screen.KEY_DECIMAL, win32con.VK_DIVIDE: Screen.KEY_DIVIDE, win32con.VK_CAPITAL: Screen.KEY_CAPS_LOCK, win32con.VK_NUMLOCK: Screen.KEY_NUM_LOCK, win32con.VK_SCROLL: Screen.KEY_SCROLL_LOCK, win32con.VK_SHIFT: Screen.KEY_SHIFT, win32con.VK_CONTROL: Screen.KEY_CONTROL, win32con.VK_MENU: Screen.KEY_MENU, } # Foreground colour lookup table. _COLOURS = { Screen.COLOUR_DEFAULT: (win32console.FOREGROUND_RED | win32console.FOREGROUND_GREEN | win32console.FOREGROUND_BLUE), Screen.COLOUR_BLACK: 0, Screen.COLOUR_RED: win32console.FOREGROUND_RED, Screen.COLOUR_GREEN: win32console.FOREGROUND_GREEN, Screen.COLOUR_YELLOW: (win32console.FOREGROUND_RED | win32console.FOREGROUND_GREEN), Screen.COLOUR_BLUE: win32console.FOREGROUND_BLUE, Screen.COLOUR_MAGENTA: (win32console.FOREGROUND_RED | win32console.FOREGROUND_BLUE), Screen.COLOUR_CYAN: (win32console.FOREGROUND_BLUE | win32console.FOREGROUND_GREEN), Screen.COLOUR_WHITE: (win32console.FOREGROUND_RED | win32console.FOREGROUND_GREEN | win32console.FOREGROUND_BLUE) } # Background colour lookup table. _BG_COLOURS = { Screen.COLOUR_DEFAULT: 0, Screen.COLOUR_BLACK: 0, Screen.COLOUR_RED: win32console.BACKGROUND_RED, Screen.COLOUR_GREEN: win32console.BACKGROUND_GREEN, Screen.COLOUR_YELLOW: (win32console.BACKGROUND_RED | win32console.BACKGROUND_GREEN), Screen.COLOUR_BLUE: win32console.BACKGROUND_BLUE, Screen.COLOUR_MAGENTA: (win32console.BACKGROUND_RED | win32console.BACKGROUND_BLUE), Screen.COLOUR_CYAN: (win32console.BACKGROUND_BLUE | win32console.BACKGROUND_GREEN), Screen.COLOUR_WHITE: (win32console.BACKGROUND_RED | win32console.BACKGROUND_GREEN | win32console.BACKGROUND_BLUE) } # Attribute lookup table _ATTRIBUTES = { 0: lambda x: x, Screen.A_BOLD: lambda x: x | win32console.FOREGROUND_INTENSITY, Screen.A_NORMAL: lambda x: x, # Windows console uses a bitmap where background is the top nibble, # so we can reverse by swapping nibbles. Screen.A_REVERSE: lambda x: ((x & 15) * 16) + ((x & 240) // 16), Screen.A_UNDERLINE: lambda x: x } def __init__(self, stdout, stdin, buffer_height, old_out, old_in, unicode_aware=False): """ :param stdout: The win32console PyConsoleScreenBufferType object for stdout. :param stdin: The win32console PyConsoleScreenBufferType object for stdin. :param buffer_height: The buffer height for this window (for testing only). :param old_out: The original win32console PyConsoleScreenBufferType object for stdout that should be restored on exit. :param old_in: The original stdin state that should be restored on exit. :param unicode_aware: Whether this Screen can use unicode or not. """ # Save off the screen details and set up the scrolling pad. info = stdout.GetConsoleScreenBufferInfo()['Window'] width = info.Right - info.Left + 1 height = info.Bottom - info.Top + 1 # Detect UTF-8 if needed and then construct the Screen. if unicode_aware is None: # According to MSDN, 65001 is the Windows UTF-8 code page. unicode_aware = win32console.GetConsoleCP() == 65001 super(_WindowsScreen, self).__init__( height, width, buffer_height, unicode_aware) # Save off the console details. self._stdout = stdout self._stdin = stdin self._last_width = width self._last_height = height self._old_out = old_out self._old_in = old_in # Windows is limited to the ANSI colour set. self.colours = 8 # Opt for compatibility with Linux by default self._map_all = False # Set of keys currently pressed. self._keys = set() def close(self, restore=True): """ Close down this Screen and tidy up the environment as required. :param restore: whether to restore the environment or not. """ if restore: # Reset the original screen settings. self._old_out.SetConsoleActiveScreenBuffer() self._stdin.SetConsoleMode(self._old_in) def map_all_keys(self, state): """ Switch on extended keyboard mapping for this Screen. :param state: Boolean flag where true means map all keys. Enabling this setting will allow Windows to tell you when any key is pressed, including metakeys (like shift and control) and whether the numeric keypad keys have been used. .. warning:: Using this means your application will not be compatible across all platforms. """ self._map_all = state def get_event(self): """ Check for any event without waiting. """ # Look for a new event and consume it if there is one. while len(self._stdin.PeekConsoleInput(1)) > 0: event = self._stdin.ReadConsoleInput(1)[0] if event.EventType == win32console.KEY_EVENT: # Pasting unicode text appears to just generate key-up # events (as if you had pressed the Alt keys plus the # keypad code for the character), but the rest of the # console input simply doesn't # work with key up events - e.g. misses keyboard repeats. # # We therefore allow any key press (i.e. KeyDown) event and # _any_ event that appears to have popped up from nowhere # as long as the Alt key is present. key_code = ord(event.Char) logger.debug("Processing key: %x", key_code) if (event.KeyDown or (key_code > 0 and key_code not in self._keys and event.VirtualKeyCode == win32con.VK_MENU)): # Record any keys that were pressed. if event.KeyDown: self._keys.add(key_code) # Translate keys into a KeyboardEvent object. if event.VirtualKeyCode in self._KEY_MAP: key_code = self._KEY_MAP[event.VirtualKeyCode] # Sadly, we are limited to Linux terminal input and so # can't return modifier states in a cross-platform way. # If the user decided not to be cross-platform, so be # it, otherwise map some standard bindings for extended # keys. if (self._map_all and event.VirtualKeyCode in self._EXTRA_KEY_MAP): key_code = self._EXTRA_KEY_MAP[event.VirtualKeyCode] else: if (event.VirtualKeyCode == win32con.VK_TAB and event.ControlKeyState & win32con.SHIFT_PRESSED): key_code = Screen.KEY_BACK_TAB # Don't return anything if we didn't have a valid # mapping. if key_code: return KeyboardEvent(key_code) else: # Tidy up any key that was previously pressed. At # start-up, we may be mid-key, so can't assume this must # always match up. if key_code in self._keys: self._keys.remove(key_code) elif event.EventType == win32console.MOUSE_EVENT: # Translate into a MouseEvent object. logger.debug("Processing mouse: %d, %d", event.MousePosition.X, event.MousePosition.Y) button = 0 if event.EventFlags == 0: # Button pressed - translate it. if (event.ButtonState & win32con.FROM_LEFT_1ST_BUTTON_PRESSED != 0): button |= MouseEvent.LEFT_CLICK if (event.ButtonState & win32con.RIGHTMOST_BUTTON_PRESSED != 0): button |= MouseEvent.RIGHT_CLICK elif event.EventFlags & win32con.DOUBLE_CLICK != 0: button |= MouseEvent.DOUBLE_CLICK return MouseEvent(event.MousePosition.X, event.MousePosition.Y, button) # If we get here, we've fully processed the event queue and found # nothing interesting. return None def has_resized(self): """ Check whether the screen has been re-sized. """ # Get the current Window dimensions and check them against last # time. re_sized = False info = self._stdout.GetConsoleScreenBufferInfo()['Window'] width = info.Right - info.Left + 1 height = info.Bottom - info.Top + 1 if width != self._last_width or height != self._last_height: re_sized = True return re_sized def _change_colours(self, colour, attr, bg): """ Change current colour if required. :param colour: New colour to use. :param attr: New attributes to use. :param bg: New background colour to use. """ # Change attribute first as this will reset colours when swapping # modes. if colour != self._colour or attr != self._attr or self._bg != bg: new_attr = self._ATTRIBUTES[attr]( self._COLOURS[colour] + self._BG_COLOURS[bg]) self._stdout.SetConsoleTextAttribute(new_attr) self._attr = attr self._colour = colour self._bg = bg def _print_at(self, text, x, y, width): """ Print string at the required location. :param text: The text string to print. :param x: The x coordinate :param y: The Y coordinate :param width: The width of the character (for dual-width glyphs in CJK languages). """ # We can throw temporary errors on resizing, so catch and ignore # them on the assumption that we'll resize shortly. try: # Move the cursor if necessary if x != self._cur_x or y != self._cur_y: self._stdout.SetConsoleCursorPosition( win32console.PyCOORDType(x, y)) # Print the text at the required location and update the current # position. self._stdout.WriteConsole(text) self._cur_x = x + width self._cur_y = y except pywintypes.error: pass def wait_for_input(self, timeout): """ Wait until there is some input or the timeout is hit. :param timeout: Time to wait for input in seconds (floating point). """ rc = win32event.WaitForSingleObject(self._stdin, int(timeout * 1000)) if rc not in [0, 258]: raise RuntimeError(rc) def _scroll(self, lines): """ Scroll the window up or down. :param lines: Number of lines to scroll. Negative numbers scroll down. """ # Scroll the visible screen up by one line info = self._stdout.GetConsoleScreenBufferInfo()['Window'] rectangle = win32console.PySMALL_RECTType( info.Left, info.Top + lines, info.Right, info.Bottom) new_pos = win32console.PyCOORDType(0, info.Top) self._stdout.ScrollConsoleScreenBuffer( rectangle, None, new_pos, " ", 0) def _clear(self): """ Clear the terminal. """ info = self._stdout.GetConsoleScreenBufferInfo()['Window'] width = info.Right - info.Left + 1 height = info.Bottom - info.Top + 1 box_size = width * height self._stdout.FillConsoleOutputAttribute( 0, box_size, win32console.PyCOORDType(0, 0)) self._stdout.FillConsoleOutputCharacter( " ", box_size, win32console.PyCOORDType(0, 0)) self._stdout.SetConsoleCursorPosition( win32console.PyCOORDType(0, 0)) def set_title(self, title): """ Set the title for this terminal/console session. This will typically change the text displayed in the window title bar. :param title: The title to be set. """ win32console.SetConsoleTitle(title) else: # UNIX compatible platform - use curses import curses import select import termios class _CursesScreen(Screen): """ Curses screen implementation. """ # Virtual key code mapping. _KEY_MAP = { 27: Screen.KEY_ESCAPE, curses.KEY_F1: Screen.KEY_F1, curses.KEY_F2: Screen.KEY_F2, curses.KEY_F3: Screen.KEY_F3, curses.KEY_F4: Screen.KEY_F4, curses.KEY_F5: Screen.KEY_F5, curses.KEY_F6: Screen.KEY_F6, curses.KEY_F7: Screen.KEY_F7, curses.KEY_F8: Screen.KEY_F8, curses.KEY_F9: Screen.KEY_F9, curses.KEY_F10: Screen.KEY_F10, curses.KEY_F11: Screen.KEY_F11, curses.KEY_F12: Screen.KEY_F12, curses.KEY_F13: Screen.KEY_F13, curses.KEY_F14: Screen.KEY_F14, curses.KEY_F15: Screen.KEY_F15, curses.KEY_F16: Screen.KEY_F16, curses.KEY_F17: Screen.KEY_F17, curses.KEY_F18: Screen.KEY_F18, curses.KEY_F19: Screen.KEY_F19, curses.KEY_F20: Screen.KEY_F20, curses.KEY_F21: Screen.KEY_F21, curses.KEY_F22: Screen.KEY_F22, curses.KEY_F23: Screen.KEY_F23, curses.KEY_F24: Screen.KEY_F24, curses.KEY_PRINT: Screen.KEY_PRINT_SCREEN, curses.KEY_IC: Screen.KEY_INSERT, curses.KEY_DC: Screen.KEY_DELETE, curses.KEY_HOME: Screen.KEY_HOME, curses.KEY_END: Screen.KEY_END, curses.KEY_LEFT: Screen.KEY_LEFT, curses.KEY_UP: Screen.KEY_UP, curses.KEY_RIGHT: Screen.KEY_RIGHT, curses.KEY_DOWN: Screen.KEY_DOWN, curses.KEY_PPAGE: Screen.KEY_PAGE_UP, curses.KEY_NPAGE: Screen.KEY_PAGE_DOWN, curses.KEY_BACKSPACE: Screen.KEY_BACK, 9: Screen.KEY_TAB, curses.KEY_BTAB: Screen.KEY_BACK_TAB # Terminals translate keypad keys, so no need for a special # mapping here. # Terminals don't transmit meta keys (like control, shift, etc), so # there's no translation for them either. } def __init__(self, win, height=None, catch_interrupt=False, unicode_aware=False): """ :param win: The window object as returned by the curses wrapper method. :param height: The height of the screen buffer to be used (for teesting only). :param catch_interrupt: Whether to catch SIGINT or not. :param unicode_aware: Whether this Screen can use unicode or not. """ # Determine unicode support if needed. if unicode_aware is None: try: encoding = getlocale()[1] if not encoding: encoding = getdefaultlocale()[1] except ValueError: encoding = os.environ.get("LC_CTYPE") unicode_aware = (encoding is not None and encoding.lower() == "utf-8") # Save off the screen details. super(_CursesScreen, self).__init__( win.getmaxyx()[0], win.getmaxyx()[1], height, unicode_aware) self._screen = win self._screen.keypad(1) # Set up basic colour schemes. self.colours = curses.COLORS # Disable the cursor. curses.curs_set(0) # Non-blocking key checks. self._screen.nodelay(1) # Store previous handlers for restoration at close self._signal_state = _SignalState() # Set up signal handler for screen resizing. self._re_sized = False self._signal_state.set(signal.SIGWINCH, self._resize_handler) # Set up signal handler for job pause/resume. self._signal_state.set(signal.SIGCONT, self._continue_handler) # Catch SIGINTs and translated them to ctrl-c if needed. if catch_interrupt: # Ignore SIGINT (ctrl-c) and SIGTSTP (ctrl-z) signals. self._signal_state.set(signal.SIGINT, self._catch_interrupt) self._signal_state.set(signal.SIGTSTP, self._catch_interrupt) # Enable mouse events curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) # Lookup the necessary escape codes in the terminfo database. self._move_y_x = curses.tigetstr("cup") self._up_line = curses.tigetstr("ri").decode("utf-8") self._down_line = curses.tigetstr("ind").decode("utf-8") self._fg_color = curses.tigetstr("setaf") self._bg_color = curses.tigetstr("setab") self._default_colours = curses.tigetstr("op") if self._default_colours: self._default_colours = self._default_colours.decode("utf-8") self._clear_line = curses.tigetstr("el").decode("utf-8") if curses.tigetflag("hs"): self._start_title = curses.tigetstr("tsl").decode("utf-8") self._end_title = curses.tigetstr("fsl").decode("utf-8") else: self._start_title = self._end_title = None self._a_normal = curses.tigetstr("sgr0").decode("utf-8") self._a_bold = curses.tigetstr("bold").decode("utf-8") self._a_reverse = curses.tigetstr("rev").decode("utf-8") self._a_underline = curses.tigetstr("smul").decode("utf-8") self._clear_screen = curses.tigetstr("clear").decode("utf-8") # Look for a mismatch between the kernel terminal and the terminfo # database for backspace. Fix up keyboard mappings if needed. kbs = curses.tigetstr("kbs").decode("utf-8") tbs = termios.tcgetattr(sys.stdin)[6][termios.VERASE] if tbs != kbs: self._KEY_MAP[ord(tbs)] = Screen.KEY_BACK # Conversion from Screen attributes to curses equivalents. self._ATTRIBUTES = { Screen.A_BOLD: self._a_bold, Screen.A_NORMAL: self._a_normal, Screen.A_REVERSE: self._a_reverse, Screen.A_UNDERLINE: self._a_underline } # Byte stream processing for unicode input. self._bytes_to_read = 0 self._bytes_to_return = b"" # We'll actually break out into low-level output, so flush any # high level buffers now. self._screen.refresh() def close(self, restore=True): """ Close down this Screen and tidy up the environment as required. :param restore: whether to restore the environment or not. """ self._signal_state.restore() if restore: self._screen.keypad(0) curses.echo() curses.nocbreak() curses.endwin() @staticmethod def _safe_write(msg): """ Safe write to screen - catches IOErrors on screen resize. :param msg: The message to write to the screen. """ try: sys.stdout.write(msg) except IOError: # Screen resize can throw IOErrors. These can be safely # ignored as the screen will be shortly reset anyway. pass def _resize_handler(self, *_): """ Window resize signal handler. We don't care about any of the parameters passed in beyond the object reference. """ curses.endwin() curses.initscr() self._re_sized = True def _continue_handler(self, *_): """ Job pause/resume signal handler. We don't care about any of the parameters passed in beyond the object reference. """ self.force_update(full_refresh=True) def _scroll(self, lines): """ Scroll the window up or down. :param lines: Number of lines to scroll. Negative numbers scroll down. """ if lines < 0: self._safe_write("{}{}".format( curses.tparm(self._move_y_x, 0, 0).decode("utf-8"), (self._up_line + self._clear_line) * -lines)) else: self._safe_write("{}{}".format(curses.tparm( self._move_y_x, self.height, 0).decode("utf-8"), (self._down_line + self._clear_line) * lines)) def _clear(self): """ Clear the Screen of all content. """ self._safe_write(self._clear_screen) sys.stdout.flush() def refresh(self): """ Refresh the screen. """ super(_CursesScreen, self).refresh() try: sys.stdout.flush() except IOError: pass @staticmethod def _catch_interrupt(signal_no, frame): """ SIGINT handler. We ignore the signal and frame info passed in. """ # Stop pep-8 shouting at me for unused params I can't control. del frame # The OS already caught the ctrl-c, so inject it now for the next # input. if signal_no == signal.SIGINT: curses.ungetch(3) elif signal_no == signal.SIGTSTP: curses.ungetch(26) def get_event(self): """ Check for an event without waiting. """ # Spin through notifications until we find something we want. key = 0 while key != -1: # Get the next key key = self._screen.getch() if key == curses.KEY_RESIZE: # Handle screen resize self._re_sized = True elif key == curses.KEY_MOUSE: # Handle a mouse event _, x, y, _, bstate = curses.getmouse() buttons = 0 # Some Linux modes only report clicks, so check for any # button down or click events. if (bstate & curses.BUTTON1_PRESSED != 0 or bstate & curses.BUTTON1_CLICKED != 0): buttons |= MouseEvent.LEFT_CLICK if (bstate & curses.BUTTON3_PRESSED != 0 or bstate & curses.BUTTON3_CLICKED != 0): buttons |= MouseEvent.RIGHT_CLICK if bstate & curses.BUTTON1_DOUBLE_CLICKED != 0: buttons |= MouseEvent.DOUBLE_CLICK return MouseEvent(x, y, buttons) elif key != -1: # Handle any byte streams first logger.debug("Processing key: %x", key) if self._unicode_aware and key > 0: if key & 0xC0 == 0xC0: self._bytes_to_return = struct.pack(b"B", key) self._bytes_to_read = bin(key)[2:].index("0") - 1 logger.debug("Byte stream: %d bytes left", self._bytes_to_read) continue elif self._bytes_to_read > 0: self._bytes_to_return += struct.pack(b"B", key) self._bytes_to_read -= 1 if self._bytes_to_read > 0: continue else: key = ord(self._bytes_to_return.decode("utf-8")) # Handle a genuine key press. logger.debug("Returning key: %x", key) if self._bytes_to_return: # UTF-8 character - resetting _bytes_to_return self._bytes_to_return = b"" return KeyboardEvent(key) if key in self._KEY_MAP: return KeyboardEvent(self._KEY_MAP[key]) elif key != -1: return KeyboardEvent(key) return None def has_resized(self): """ Check whether the screen has been re-sized. """ re_sized = self._re_sized self._re_sized = False return re_sized def _change_colours(self, colour, attr, bg): """ Change current colour if required. :param colour: New colour to use. :param attr: New attributes to use. :param bg: New background colour to use. """ # Change attribute first as this will reset colours when swapping # modes. if attr != self._attr: self._safe_write(self._a_normal) if attr != 0: self._safe_write(self._ATTRIBUTES[attr]) self._attr = attr self._colour = None self._bg = None # next check for default colours - which reset both fg and bg. if colour == Screen.COLOUR_DEFAULT or bg == Screen.COLOUR_DEFAULT: if self._default_colours: self._safe_write(self._default_colours) self._colour = colour if colour == Screen.COLOUR_DEFAULT else None self._bg = bg if bg == Screen.COLOUR_DEFAULT else None else: colour = Screen.COLOUR_WHITE if colour == Screen.COLOUR_DEFAULT else colour bg = Screen.COLOUR_BLACK if bg == Screen.COLOUR_DEFAULT else bg # Now swap colours if required. if colour != self._colour: self._safe_write(curses.tparm( self._fg_color, colour).decode("utf-8")) self._colour = colour if bg != self._bg: self._safe_write(curses.tparm( self._bg_color, bg).decode("utf-8")) self._bg = bg def _print_at(self, text, x, y, width): """ Print string at the required location. :param text: The text string to print. :param x: The x coordinate :param y: The Y coordinate :param width: The width of the character (for dual-width glyphs in CJK languages). """ # Move the cursor if necessary cursor = u"" if x != self._cur_x or y != self._cur_y: cursor = curses.tparm(self._move_y_x, y, x).decode("utf-8") # Print the text at the required location and update the current # position. try: self._safe_write(cursor + text) except UnicodeEncodeError: # This is probably a sign that the user has the wrong locale. # Try to soldier on anyway. self._safe_write(cursor + "?" * len(text)) # Update cursor position for next time... self._cur_x = x + width self._cur_y = y def wait_for_input(self, timeout): """ Wait until there is some input or the timeout is hit. :param timeout: Time to wait for input in seconds (floating point). """ try: select.select([sys.stdin], [], [], timeout) except select.error: # Any error will almost certainly result in a a Screen. Ignore. pass def set_title(self, title): """ Set the title for this terminal/console session. This will typically change the text displayed in the window title bar. :param title: The title to be set. """ if self._start_line is not None: self._safe_write("{}{}{}".format(self._start_title, title, self._end_title)) class _SignalState(object): """ Save previous user signal state while setting signals. Used for signal restoration when asciimatics no longer has control of the user program. """ def __init__(self): self._old_signal_states = [] def set(self, signalnum, handler): """ Set signal handler and record their previous values. :param signalnum: The const/enum matching to the signal to be set. :param handler: The function/const to set the signal to """ old_handler = signal.getsignal(signalnum) # Some environments may install a non-Python handler (which returns None at this point). # We can't reinstate these, so just reset the default handler in such cases. if old_handler is None: old_handler = signal.SIG_DFL self._old_signal_states.append((signalnum, old_handler)) signal.signal(signalnum, handler) def restore(self): """ Restore saved signals to their previous handles. """ for signalnum, handler in self._old_signal_states: signal.signal(signalnum, handler) self._old_signal_states = []