Shofel2_T124_python/venv/lib/python3.10/site-packages/asciimatics/screen.py

2694 lines
99 KiB
Python

# -*- 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 = []