openpdu-libs/usr/python2.7/site-packages/oled/virtual.py

372 lines
12 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
import time
from PIL import Image, ImageDraw, ImageFont
import oled.mixin as mixin
from oled.threadpool import threadpool
pool = threadpool(4)
def calc_bounds(xy, entity):
"""
For an entity with width and height attributes, determine
the bounding box if were positioned at (x, y).
"""
left, top = xy
right, bottom = left + entity.width, top + entity.height
return [left, top, right, bottom]
def range_overlap(a_min, a_max, b_min, b_max):
"""
Neither range is completely greater than the other
"""
return (a_min < b_max) and (b_min < a_max)
class viewport(mixin.capabilities):
def __init__(self, device, width, height):
self.capabilities(width, height, rotate=0, mode=device.mode)
self._device = device
self._backing_image = Image.new(self.mode, self.size)
self._position = (0, 0)
self._hotspots = []
def display(self, image):
assert(image.mode == self.mode)
assert(image.size == self.size)
self._backing_image.paste(image)
self.refresh()
def set_position(self, xy):
self._position = xy
self.refresh()
def add_hotspot(self, hotspot, xy):
"""
Add the hotspot at (x, y). The hotspot must fit inside the bounds
of the virtual device. If it does not then an AssertError is raised.
"""
(x, y) = xy
assert(0 <= x <= self.width - hotspot.width)
assert(0 <= y <= self.height - hotspot.height)
# TODO: should it check to see whether hotspots overlap each other?
# Is sensible to _allow_ them to overlap?
self._hotspots.append((hotspot, xy))
def remove_hotspot(self, hotspot, xy):
"""
Remove the hotspot at (x, y): Any previously rendered image where the
hotspot was placed is erased from the backing image, and will be
"undrawn" the next time the virtual device is refreshed. If the
specified hotspot is not found (x, y), a ValueError is raised.
"""
self._hotspots.remove((hotspot, xy))
eraser = Image.new(self.mode, hotspot.size)
self._backing_image.paste(eraser, xy)
def is_overlapping_viewport(self, hotspot, xy):
"""
Checks to see if the hotspot at position (x, y)
is (at least partially) visible according to the
position of the viewport
"""
l1, t1, r1, b1 = calc_bounds(xy, hotspot)
l2, t2, r2, b2 = calc_bounds(self._position, self._device)
return range_overlap(l1, r1, l2, r2) and range_overlap(t1, b1, t2, b2)
def refresh(self):
should_wait = False
for hotspot, xy in self._hotspots:
if hotspot.should_redraw() and self.is_overlapping_viewport(hotspot, xy):
pool.add_task(hotspot.paste_into, self._backing_image, xy)
should_wait = True
if should_wait:
pool.wait_completion()
im = self._backing_image.crop(box=self._crop_box())
self._device.display(im)
del im
def _crop_box(self):
(left, top) = self._position
right = left + self._device.width
bottom = top + self._device.height
assert(0 <= left <= right <= self.width)
assert(0 <= top <= bottom <= self.height)
return (left, top, right, bottom)
class hotspot(mixin.capabilities):
"""
A hotspot (`a place of more than usual interest, activity, or popularity`)
is a live display which may be added to a virtual viewport - if the hotspot
and the viewport are overlapping, then the :func:`update` method will be
automatically invoked when the viewport is being refreshed or its position
moved (such that an overlap occurs).
You would either:
* create a ``hotspot`` instance, suppling a render function (taking an
:py:mod:`PIL.ImageDraw` object, ``width`` & ``height`` dimensions. The
render function should draw within a bounding box of (0, 0, width,
height), and render a full frame.
* sub-class ``hotspot`` and override the :func:``should_redraw`` and
:func:`update` methods. This might be more useful for slow-changing
values where it is not necessary to update every refresh cycle, or
your implementation is stateful.
"""
def __init__(self, width, height, draw_fn=None):
self.capabilities(width, height, rotate=0) # TODO: set mode?
self._fn = draw_fn
def paste_into(self, image, xy):
im = Image.new(image.mode, self.size)
draw = ImageDraw.Draw(im)
self.update(draw)
image.paste(im, xy)
del draw
del im
def should_redraw(self):
"""
Override this method to return true or false on some condition
(possibly on last updated member variable) so that for slow changing
hotspots they are not updated too frequently.
"""
return True
def update(self, draw):
if self._fn:
self._fn(draw, self.width, self.height)
class snapshot(hotspot):
"""
A snapshot is a `type of` hotspot, but only updates once in a given
interval, usually much less frequently than the viewport requests refresh
updates.
"""
def __init__(self, width, height, draw_fn=None, interval=1.0):
super(snapshot, self).__init__(width, height, draw_fn)
self.interval = interval
self.last_updated = 0.0
def should_redraw(self):
"""
Only requests a redraw after ``interval`` seconds have elapsed
"""
return time.time() - self.last_updated > self.interval
def paste_into(self, image, xy):
super(snapshot, self).paste_into(image, xy)
self.last_updated = time.time()
class terminal(object):
"""
Provides a terminal-like interface to a device (or a device-like object
that has :class:`mixin.capabilities` characteristics).
"""
def __init__(self, device, font=None, color="white", bgcolor="black", tabstop=4, line_height=None, animate=True):
self._device = device
self.font = font or ImageFont.load_default()
self.color = color
self.bgcolor = bgcolor
self.animate = animate
self.tabstop = tabstop
self._cw, self._ch = (0, 0)
for i in range(32, 128):
w, h = self.font.getsize(chr(i))
self._cw = max(w, self._cw)
self._ch = max(h, self._ch)
self._ch = line_height or self._ch
self.width = device.width // self._cw
self.height = device.height // self._ch
self.size = (self.width, self.height)
self._backing_image = Image.new(self._device.mode, self._device.size, self.bgcolor)
self._canvas = ImageDraw.Draw(self._backing_image)
self.clear()
def clear(self):
"""
Clears the display and resets the cursor position to (0, 0).
"""
self._cx, self._cy = (0, 0)
self._canvas.rectangle(self._device.bounding_box, fill=self.bgcolor)
self.flush()
def println(self, text=""):
"""
Prints the supplied text to the device, scrolling where necessary.
The text is always followed by a newline.
"""
self.puts(text)
self.newline()
def puts(self, text):
"""
Prints the supplied text, handling special character codes for carriage
return (\\r), newline (\\n), backspace (\\b) and tab (\\t).
If the ``animate`` flag was set to True (default), then each character
is flushed to the device, giving the effect of 1970's teletype device.
"""
for line in str(text).split("\n"):
for char in line:
if char == '\r':
self.carriage_return()
elif char == '\n':
self.newline()
elif char == '\b':
self.backspace()
elif char == '\t':
self.tab()
else:
self.putch(char, flush=self.animate)
def putch(self, ch, flush=True):
"""
Prints the specific character, which must be a valid printable ASCII
value in the range 32..127 only.
"""
assert(32 <= ord(ch) <= 127)
w = self.font.getsize(ch)[0]
if self._cx + w >= self._device.width:
self.newline()
self.erase()
self._canvas.text((self._cx, self._cy), text=ch, font=self.font, fill=self.color)
self._cx += w
if flush:
self.flush()
def carriage_return(self):
"""
Returns the cursor position to the left-hand side without advancing
downwards.
"""
self._cx = 0
def tab(self):
"""
Advances the cursor position to the next (soft) tabstop.
"""
soft_tabs = self.tabstop - ((self._cx // self._cw) % self.tabstop)
for _ in range(soft_tabs):
self.putch(" ", flush=False)
def newline(self):
"""
Advances the cursor position ot the left hand side, and to the next
line. If the cursor is on the lowest line, the displayed contents are
scrolled, causing the top line to be lost.
"""
self.carriage_return()
if self._cy + (2 * self._ch) >= self._device.height:
# Simulate a vertical scroll
copy = self._backing_image.crop((0, self._ch, self._device.width, self._device.height))
self._backing_image.paste(copy, (0, 0))
self._canvas.rectangle((0, copy.height, self._device.width, self._device.height), fill=self.bgcolor)
else:
self._cy += self._ch
self.flush()
if self.animate:
time.sleep(0.2)
def backspace(self):
"""
Moves the cursor one place to the left, erasing the character at the
current position. Cannot move beyound column zero, nor onto the
previous line
"""
if self._cx + self._cw >= 0:
self.erase()
self._cx -= self._cw
self.flush()
def erase(self):
"""
Erase the contents of the cursor's current postion without moving the
cursor's position.
"""
self._canvas.rectangle((self._cx, self._cy, self._cx + self._cw, self._cy + self._ch), fill=self.bgcolor)
def flush(self):
"""
Cause the current backing store to be rendered on the nominated device.
"""
self._device.display(self._backing_image)
class history(mixin.capabilities):
"""
Wraps a device (or emulator) to provide a facility to be able to make a
savepoint (a point at which the screen display can be "rolled-back" to).
This is mostly useful for displaying transient error/dialog messages
which could be subsequently dismissed, reverting back to the previous
display.
"""
def __init__(self, device):
self.capabilities(device.width, device.height, rotate=0, mode=device.mode)
self._savepoints = []
self._device = device
self._last_image = None
def display(self, image):
self._last_image = image.copy()
self._device.display(image)
def savepoint(self):
"""
Copies the last displayed image.
"""
if self._last_image:
self._savepoints.append(self._last_image)
self._last_image = None
def restore(self, drop=0):
"""
Restores the last savepoint. If ``drop`` is supplied and greater than
zero, then that many savepoints are dropped, and the next savepoint is
restored.
"""
assert(drop >= 0)
while drop > 0:
self._savepoints.pop()
drop -= 1
img = self._savepoints.pop()
self.display(img)
def __len__(self):
"""
Indication of the number of savepoints retained.
"""
return len(self._savepoints)