"""Module to support graphic display.
This is a convergence layer to provide unified access to graphic
displays. Typically, the smallest item that can be displayed by those
devices, is a single pixel. Possibly, colors beyond the gray-spectrum
are supported.
"""
__author__ = "Oliver Maye"
__version__ = "0.1"
__all__ = ["Color",
"GrayScale1", "GrayScale2", "GrayScale4", "GrayScale8",
"Image",
"GraphicDisplay", ]
from abc import abstractmethod
from .display_text import TextDisplay
from .penum import Enum, unique, idiotypic
from .systypes import ErrorCode
# Colors and color names
[docs]
class Color(int):
"""Colors that can be used when drawing to the LCD.
The underlying hardware might support different color spaces, like
8 bit gray scale or 24 bit true color levels.
"""
[docs]
@unique
@idiotypic
class GrayScale1(Enum):
"""Mnemonics for the black/white color space.
The underlying color-space is 1-bit b/w scale allowing for just 2
nuances - black and white.
"""
BLACK = 0x00 # 1bit color space, full black
WHITE = 0xFF # 1bit color space, bright white
[docs]
@unique
@idiotypic
class GrayScale2(Enum):
"""Mnemonics for a simple 2-bit mono-chromatic color space.
The underlying color-space is 2-bit gray scale allowing for just 4
nuances.
"""
BLACK = 0x00 # 2bit color space, full black
DARK_GRAY = 0x55 # 2bit color space, dark gray
LIGHT_GRAY = 0xAA # 2bit color space, light gray
WHITE = 0xFF # 2bit color space, bright white
[docs]
@unique
@idiotypic
class GrayScale4(Enum):
"""Mnemonics for a 4-bit mono-chromatic color space.
The underlying color-space is 4-bit gray scale allowing for 16
nuances.
"""
BLACK = 0x00 # 4bit color space, full black
DIRTY_BLACK = 0x11 #
MEDIUM_BLACK = 0x22 #
COAL_GRAY = 0x33 #
LIGHT_BLACK = 0x44 #
DARK_GRAY = 0x55 # 4bit color space, dark gray
ZINC_GRAY = 0x66 #
LEAD_GRAY = 0x77 #
MEDIUM_GRAY = 0x88 # 4bit color space, medium gray
SPANISH_GRAY = 0x99 #
LIGHT_GRAY = 0xAA # 4bit color space, light gray
SILVER_GRAY = 0xBB #
DARK_WHITE = 0xCC #
MEDIUM_WHITE = 0xDD #
DIRTY_WHITE = 0xEE #
WHITE = 0xFF # 4bit color space, bright white
[docs]
@unique
@idiotypic
class GrayScale8(Enum):
"""Mnemonics to short-cut official names for shades of black, white and gray.
The underlying color-space is 8-bit gray scale allowing for a total
of 256 nuances. Only some of them are named.
Also see:
https://en.wikipedia.org/wiki/Shades_of_black
https://en.wikipedia.org/wiki/Shades_of_gray
https://en.wikipedia.org/wiki/Shades_of_white
"""
BLACK = 0x00 # Full black, dot always set
VAMPIRE_BLACK = 0x08
ONYX = 0x0F
CHINESE_BLACK = 0x14
EERIE_BLACK = 0x1B
DARK_CHARCOAL = 0x33
JET_BLACK = 0x34
TUNDORA = 0x40
DAVYS_GRAY = 0x55
DIM_GRAY = 0x69
SONIC_SILVER = 0x75
GRAY = 0x80 # Right between black and white
SPANISH_GRAY = 0x98
DARK_GRAY = 0xA9
SILVER_CHALICE = 0xAC
MEDIUM_GRAY = 0xBE
SILVER = 0xC0
LIGHT_GRAY = 0xD3
GAINSBORO = 0xDC
WHITE_SMOKE = 0xF5
WHITE = 0xFF # Bright white, dot not set
[docs]
class Image():
"""Helper class to represent an image.
"""
def __init__(self):
self._colorspace = ColorSpace.GRAY_1
self._width = 0
self._height = 0
self._data = None
@property
def width(self):
return self._width
@property
def height(self):
return self._height
@property
def colorspace(self):
return self._colorspace
[docs]
class GraphicDisplay(TextDisplay):
"""Abstract API class for displays capable of showing graphics.
Implementations of this interface are expected to drive a specific
hardware, which is usually an LCD or OLED.
Beyond everything that is inherited from TextDisplay, graphic displays
know of different colors and inks.
The concept of ink is a short cut for logic operations executed when
drawing a possibly colored pixel on the background. Pixel data may
originate from drawing (and anti-aliasing) a line or from the font
when printing a character. Logic is computed bit-wise of the pixel
and background color values as follows:
=========== ==============================================================
Ink Effect
=========== ==============================================================
REPLACE Old content is simply replaced. No logic applies.
OVERLAY New content is logically ORed with the old one.
Usually that leads to the screen area becoming darker
as more pixels are set or gray scales sum up.
MASK New content is logically ANDed with the old one
resulting in lighter screen area. Only those pixels
keep set, that were set before and would be set by
the new content.
INVERT New content is logically XORed with the old one.
Original content is preserved were the new content has
white pixels, while the original content is negated
were the new content has black pixels.
=========== ==============================================================
"""
# Mnemonics for the ink style.
# The ink style defines the logical operation to be performed when
# drawing to the display.
INK_STYLE_REPLACE = 0 # Simply substitute the background, no logic at all
INK_STYLE_OVERLAY = 1 # Overlay, add, logic OR
INK_STYLE_MASK = 2 # Mask, wipe out, logic AND
INK_STYLE_INVERT = 3 # Invert, logic XOR
def __init__(self):
# Derived attributes
super().__init__()
# Own attributes
self._widthPixel = 0
self._heightPixel = 0
self._colorspace= ColorSpace.GRAY_1 # Color space.
self._backgroundColor = GrayScale1.BLACK
self._ink = GraphicDisplay.INK_STYLE_REPLACE
#############################
# Module API
#############################
[docs]
def open(self, paramDict):
ret = super().open(paramDict)
if ret.isOk():
ret = self.goToPixel(0, 0)
return ret
#############################
# Inherited API
#############################
#
# Non-public, internal driver interface
# Wherever possible, method signatures are oriented on their
# public counterparts.
#
def _drvClearScreen( self ):
"""Clear all contents from screen.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
ret = self.drawBox(self._widthPixel, self._heightPixel,
self._backgroundColor)
return ret
#
# Non-public helper methods
#
def _invertColor(self, space, value):
ret = 0
if space <= ColorSpace.GRAY_8:
ret = 0xFF - value
elif space == ColorSpace.PALETTE4:
ret = 0x0F - value
elif space == ColorSpace.PALETTE8:
ret = 0xFF - value
elif space == ColorSpace.RGB_16:
ret = 0xFFFF - value
elif space == ColorSpace.RGB_24:
ret = 0xFFFFFF - value
elif space == ColorSpace.RGB_32:
ret = 0xFFFFFFFF - value
else:
ret = 0
return ret
def _updateLineFeed(self):
ret = ErrorCode.errOk
# Try line feed
iTemp = self._currentY + self.font.charHeight
if (iTemp + self.font.charHeight <= self._heightPixel):
ret = self.goToChar( 0, iTemp)
if self._screenPolicy == TextDisplay.SCREEN_POLICY_INVERT:
ret = self.drawBox( self._widthPixel, self.font.charHeight,
self._backgroundColor, self.INK_STYLE_REPLACE)
else:
# reached the end of screen: apply the screen full policy
if (self._screenPolicy == self.SCREEN_POLICY_CLEAR):
ret = self.clearScreen()
if (self._screenPolicy == self.SCREEN_POLICY_INVERT):
self.goToChar(0, 0)
self._backgroundColor = self._invertColor(self._colorspace, self._backgroundColor)
ret = self.drawBox( self._widthPixel, self._font.charHeight,
self._backgroundColor, self.INK_STYLE_REPLACE)
if (self._screenPolicy == self.SCREEN_POLICY_SCROLL):
# Number of rows ( pixels ) that should be scrolled
ret = self._drvScrolLV( self._font.charHeight )
# Go to the beginning of the row that should be replaced
ret = self._goToPixel(0, self._currentY)
else:
ret = ErrorCode.errInadequate
return ret
def _updateNextChar(self):
ret = ErrorCode.errOk
self._currentX += self._font.charWidth
if( self._currentX + self._font.charWidth <= self._widthPixel ):
ret = self.goToPixel( self._currentX, self._currentY )
else:
ret = self._updateLineFeed()
return ret
#
# Public API
#
[docs]
def moveCursorToChar( self, x, y ):
ret = ErrorCode.errOk
if not self._font:
ret = ErrorCode.errInadequate
else:
xp = x * self._font.charWidth
yp = y * self._font.charHeight
if (xp < 0) or (xp > self._widthPixel) or \
(yp < 0) or (yp > self._heightPixel):
ret = ErrorCode.errSpecRange
else:
ret = self._drvMoveCursorTo(xp, yp)
if ret.isOk():
self._cursorX = xp
self._cursorY = yp
return ret
[docs]
def moveCursorByChar( self, x, y ):
ret = ErrorCode.errOk
if not self._font:
ret = ErrorCode.errInadequate
else:
absX = self._cursorX + x*self._font.charWidth
absY = self._cursorY + y*self._font.charHeight
if (absX < 0) or (absX > self._widthPixel) or \
(absY < 0) or (absY > self._heightPixel):
ret = ErrorCode.errSpecRange
else:
ret = self._drvMoveCursorTo( absX, absY )
if ret.isOk():
self._cursorX = absX
self._cursorY = absY
return ret
[docs]
def goToChar( self, x, y ):
ret = ErrorCode.errOk
if not self._font:
ret = ErrorCode.errInadequate
else:
xp = x * self._font.charWidth
yp = y * self._font.charHeight
ret = self.goToPixel(xp, yp)
return ret
[docs]
def goByChar( self, x, y ):
ret = ErrorCode.errOk
if not self._font:
ret = ErrorCode.errInadequate
else:
absX = self._currentX + x*self._font.charWidth
absY = self._currentY + y*self._font.charHeight
ret = self.goToPixel(absX, absY)
return ret
[docs]
def clearScreen(self):
ret = self._drvScrollV( 0 )
if ret.isLight():
ret = self.goToPixel( 0, 0 )
if ret.isOk():
ret = self._drvClearScreen()
return ret
[docs]
def setFont( self, font ):
ret = super().setFont(font)
if ret.isOk():
self._widthChar = self._widthPixel / self._font.charWidth
self._heightChar= self._heightPixel/ self._font.charHeight
return ret
[docs]
def printChar( self, code ):
"""Print a character at the current position of the virtual drawing cursor.
This current position defines the upper left corner of the
character bitmap to draw. The size of that bitmap is defined by
the current font. The content of the bitmap is defined
by the given character code and the corresponding font entry.
The color is also defined by that font entry.
Depending on the current ink set, logic operation between the
character color and the background may be applied.
Upon successful return, the virtual drawing cursor is moved by
one character position. Usually, this means moving right by the
character's width. At the end of a line, the virtual drawing
cursor is automatically placed at the beginning of the next line.
At the end of the screen, the screen-full policy applies.
Also see: :meth:`TextDisplay.printChar`
:param int code: The character to print, given as an ASCII code.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
ret = ErrorCode.errOk
if( self._font is None ):
ret = ErrorCode.errInadequate
elif( (self._currentX + self._font.charWidth > self._widthPixel) or \
(self._currentY + self._font.charHeight > self._heightPixel) ):
ret = ErrorCode.errSpecRange
else:
# Characters with special treatment
if code == 8: # backspace
if self.goByChar( -1, 0 ).isOk():
ret = self.printChar( ' ' )
self.goByChar( -1, 0 )
elif code == 9: # Tab
iTemp = self._tabsize * self._font.charWidth
iTemp = self._currentX + iTemp - (self._currentX % iTemp)
if( iTemp + self._font.charWidth <= self._widthPixel ):
ret = self.goToPixel( iTemp, self._currentY )
else:
ret = self._updateLineFeed()
elif (code == 10) or (code == 13): # Line feed, carriage return
ret = self._updateLineFeed()
if ret == ErrorCode.errInadequate:
self.goToPixel( self._widthPixel - 1, self._currentY )
elif code == 12: # Form feed
ret = self.goToChar( 0, 0 )
else:
ret = self._drvPrintChar(code)
# Update virtual cursor position to place next character at
if ret.isOk():
ret = self._updateNextChar()
return ret
#############################
# Specific API
#############################
#
# Non-public, internal driver interface
# Wherever possible, method signatures are oriented on their
# public counterparts.
#
def _drvDrawPixel( self, x, y, color ):
"""Draw a single pixel with the given color and ink logic.
The implemantation can rely on :meth:`goToPixel` has been
executed, before.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
del x, y
ret = self.drawBox( 1, 1, color )
return ret
@abstractmethod
def _drvDrawBox( self, width, height, color ):
"""Draw a box with the given color and ink logic.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
del width, height, color
return ErrorCode.errNotSupported
def _drvDrawImage( self, image ):
"""Draw an image at the current position with the given ink logic.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
del image
return ErrorCode.errNotSupported
def _drvScrollHstart( self, direction, start_row, end_row,
start_col, end_col, scroll_step ):
"""Execute a horizontal scroll of the content displayed.
:param int direction: The direction of scrolling. One of :attr:`TextDisplay.SCROLL_DIR_LEFT` or :attr:`TextDisplay.SCROLL_DIR_RIGHT`.
:param int start_row: Starting row number.
:param int end_row: End row number.
:param int start_col: Start column number.
:param int end_col: End column number.
:param int scroll_step: The time interval in frames per scroll step
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
del direction, start_row, end_row, start_col, end_col, scroll_step
return ErrorCode.errNotImplemented
def _drvScrollHstop( self ):
"""Deactivate (stop) the horizontal scrolling.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
return ErrorCode.errNotImplemented
#
# Public API
#
@property
def widthPixel(self):
"""Retrieve the display width, measured in characters."""
return self._widthPixel
@property
def heightPixel(self):
"""Retrieve the display height in characters."""
return self._heightPixel
@property
def colorspace(self):
"""Retrieve the display color space."""
return self._colorspace
@property
def backgroundColor(self):
"""Retrieve the current background color."""
return self._backgroundColor
@property
def ink(self):
"""Retrieve the ink logic."""
return self._ink
@ink.setter
def ink(self, value ):
self._ink = value
[docs]
def goToPixel( self, x, y ):
"""Move the internal drawing cursor or ``current position`` to the given absolute position.
The internal cursor is the starting point when drawing lines,
characters etc. and is not visible to the user. Instead, it is
a virtual cursor.
It should not be mixed up with the hardware cursor that can be
controlled by :meth:`TextDisplay.moveCursorToChar`.
The same cursor is also manipulated by the :meth:`TextDisplay.goToChar`
method.
Also see: :meth:`goByPixel`
:param int x: The new horizontal position given in pixels.
:param int y: The new vertical position given in pixels.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
ret = ErrorCode.errOk
if (x < 0) or (x > self._widthPixel) or \
(y < 0) or (y > self._heightPixel):
ret = ErrorCode.errSpecRange
else:
ret = self._drvGoTo(x, y)
if ret.isOk():
self._currentX = x
self._currentY = y
return ret
[docs]
def goByPixel( self, x, y ):
"""Move the virtual drawing cursor by a relative distance, expressed in pixels.
Actually, the new position calculates as the arithmetic sum of
the internal ``current position`` and the arguments.
If the movement would exceed the screen limits, nothing is moved and the
function returns an error code.
Positive values will move the virtual cursor to the right or
downwards, while negative values will move it to the left or
upwards, respectively.
Also see: :meth:`goToPixel`, :meth:`TextDisplay.goByChar`
:param int x: The horizontal displacement in pixels.
:param int y: The vertical distance to move, given in pixels.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
return self.goToPixel(x+self._currentX, y+self._currentY)
[docs]
def drawBox( self, width, height, color ):
"""Draw a filled rectangle.
The upper left corner of the rectangle is defined by the virtual
drawing cursor. The size is given by the parameters, as well
as the color and ink type to draw with.
A width of 1 will produce a vertical line, a height of 1 will
result in a horizontal line. If both, width and height are 1,
the box will be a single pixel.
Note that the rectangle's border color is the same as the fill
color.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
On return, the virtual drawing cursor is not changed.
:param int width: The horizontal width of the box, given in pixels.
:param int height: The vertical size of the box to draw, given in pixels.
:param int color: The color or shade of grey, that the box shall be of.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
return self._drvDrawBox(width, height, color)
[docs]
def drawPixel( self, x, y, color ):
"""Set a pixel at the given position with the given color and ink.
Moves the virtual drawing cursor to the given position and draws
a 1x1 box with the given color.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
Also see: :meth:`drawBox`
:param int x: The x coordinate of the pixel to draw.
:param int y: The y coordinate of the pixel to draw.
:param int color: The color to draw with.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
ret = self.goToPixel(x, y)
if ret.isOk():
ret = self._drvDrawPixel(x, y, color)
return ret
[docs]
def drawHLine( self, width, color ):
"""Draw a horizontal line.
Drawing starts from the virtual drawing cursor's current
position and extends over the specified width.
Upon return, the position of the virtual drawing cursor is left
unchanged.
In terms of speed, this function is preferable over the more general
function :meth:`drawLine`.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
Also see: :meth:`drawVLine`, :meth:`drawLine`
:param int width: The length of the line, given in pixels.
:param int color: The color of the line.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
return self.drawBox(width, 1, color)
[docs]
def drawVLine( self, height, color ):
"""Draw a vertical line.
Drawing starts at the current position of the virtual drawing
cursor. The length of the line is specified as a parameter.
On return, the virtual drawing cursor is still at the same
position.
In terms of speed, this function is preferable over the more
general function :meth:`drawLine`.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
Also see: :meth:`drawHLine`, :meth:`drawLine`
:param int height: The vertical length of the line, given in pixels.
:param int color: The color of the line.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
return self.drawBox(1, height, color)
[docs]
def drawLine( self, xEnd, yEnd, color ):
"""Draw a line in an arbitrary direction.
Drawing starts from the current position of the virtual drawing
cursor. The end point is given by the parameters.
Upon return, the virtual drawing cursor is at the end point
position.
If the caller knows, that the line is simply vertical or
horizontal, :meth:`drawVLine` or :meth:`drawHLine` should be
called, instead. Otherwise, this function will find out and
optimize performance that way.
For all other, non-trivial lines, implementation uses the
Bresenham algorithm to construct the line pixel by pixel.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
:param int xEnd: The x coordinate of the end point, given in pixels.
:param int yEnd: The vertical component of the end point, given in pixels.
:param int color: The color of the line.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
ret = ErrorCode.errOk
x0 = self._currentX
y0 = self._currentY
if x0==xEnd:
ret = self.drawVLine( yEnd-y0, color )
elif y0==yEnd:
ret = self.drawHLine( xEnd-x0, color )
else:
if( x0 < xEnd ):
dx = xEnd - x0
sx = 1
else:
dx = x0 - xEnd
sx = -1
if y0 < yEnd:
dy = y0 - yEnd
sy = 1
else:
dy = yEnd - y0
sy = -1
err = dx + dy
while True: # loop
self.drawPixel(x0, y0, color)
if (x0 == xEnd) and (y0 == yEnd):
break
e2 = 2 * err
if e2 > dy: # e_xy+e_x > 0
err += dy
x0 += sx
if (e2 < dx): # e_xy+e_y < 0
err += dx
y0 += sy
ret = self.goToPixel( xEnd, yEnd )
return ret
[docs]
def drawRectangle( self, width, height, color ):
"""Draw an empty rectangle.
The upper left corner of the rectangle is defined by the virtual
drawing cursor. The size is given by the parameters as well
as the color and ink type to draw with.
A width of 1 will produce a vertical line, a height of 1 will
result in a horizontal line. If both, width and height are 1,
the result will be a single pixel.
Note that the rectangle's border width is always one pixel.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
On return, the virtual drawing cursor is not changed.
Also see: :meth:`drawBox`
:param int width: The horizontal width of the rectangle, given in pixels.
:param int height: The vertical size of the rectangle to draw, given in pixels.
:param int color: The color or shade of grey, that the box line shall be of.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
ret = ErrorCode.errOk
if (width > 0) and (height > 0):
xOrg = self._currentX
yOrg = self._currentY
ret = self.drawHLine( width, color )
ret = self.drawVLine( height, color )
self.goToPixel( xOrg, yOrg + height - 1 )
ret = self.drawHLine( width, color )
self.goToPixel( xOrg + width - 1, yOrg )
ret = self.drawVLine( height, color )
self.goToPixel( xOrg, yOrg )
return ret
[docs]
def drawCircle( self, radius, color ):
"""Draw a circle.
The center of the circle is defined by the current position
of the virtual drawing cursor. This position is not changed on
return.
The radius of the circle is provided as a parameter.
Depending on the current ink set, logic operation between the
given (foreground-)color and the background may be applied.
The implementation uses a variant of the Bresenham algorithm.
:param int radius: The radius of the circle, given in pixels. Should be positive.
:param int color: The color of the circle.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
# The current position is the center (x0, y0) of the circle.
x0 = self._currentX
y0 = self._currentY
f = 1 - radius
ddF_x = 0
ddF_y = -2 * radius
x = 0
y = radius
self.drawPixel( x0, y0 + radius, color )
self.drawPixel( x0, y0 - radius, color )
self.drawPixel( x0 + radius, y0, color )
self.drawPixel( x0 - radius, y0, color )
while (x < y):
if (f >= 0):
y -= 1
ddF_y += 2
f += ddF_y
x += 1
ddF_x += 2
f += ddF_x + 1
self.drawPixel( x0 + x, y0 + y, color )
self.drawPixel( x0 - x, y0 + y, color )
self.drawPixel( x0 + x, y0 - y, color )
self.drawPixel( x0 - x, y0 - y, color )
self.drawPixel( x0 + y, y0 + x, color )
self.drawPixel( x0 - y, y0 + x, color )
self.drawPixel( x0 + y, y0 - x, color )
self.drawPixel( x0 - y, y0 - x, color )
ret = self.goToPixel( x0, y0 )
return ret
[docs]
def drawImage( self, image ):
"""Draw an image at the current position of the virtual drawing cursor.
The size and data of the image are encapsulated in the
``image`` parameter.
Depending on the current ink set, logic operation between the
image data/color and the background may be applied.
On return, the position of the virtual drawing cursor is left
unchanged.
:param int image: The image including meta data like width and height.
:return: An error code indicating either success or the reason of failure.
:rtype: ErrorCode
"""
return self._drvDrawImage( image )