# -*- coding: utf-8 -*-
"""Drawing Canvas."""
# Standard library imports
from collections import deque
# Local imports
from canvascli.utils import check_color
[docs]class Canvas(object):
"""
Drawing Canvas.
An empty canvas can be defined but it needs to be created to allow drawing.
These canvas is based on 1-indexing, so all coordinates (x, y points) for
drawing must be greater than 1.
The internal representation of the canvas is based on 0-indexing like all
python containers and uses a list of lists.
Notes:
------
- This canvas currently only support an integer coordinate system.
- Currently only horizontal or vertical lines are supported.
- Horizontal and vertical lines will be drawn using 'x'
- The horizontal and vertical border of the canvas will be drawn using
'-' and '|' respectively.
"""
MARKER = 'x'
HBORDER = '-'
VBORDER = '|'
DEFAULT_COLOR = 'o'
def __init__(self, width=None, height=None):
"""Drawing Canvas."""
self._width = width
self._height = height
self._canvas = None
# Canvas is defined as a lists of lists using 0-based indexing
if width is not None and height is not None:
self.create(width, height)
elif width or height:
raise TypeError('Must provide both with and height arguments!')
# --- Magic methods
# -------------------------------------------------------------------------
def __str__(self):
"""String representation of the Canvas."""
if self._canvas is not None:
data = self._top_bottom + '\n'
for row_values in self._canvas:
data += self.VBORDER
for value in row_values:
data += ' ' if value is None else value
data += self.VBORDER + '\n'
data += self._top_bottom
else:
data = '[]'
return data
def __unicode__(self):
"""String representation of the Canvas."""
return self.__str__()
def __repr__(self):
"""Representation of the Canvas."""
canvas = self._canvas if self._canvas is not None else []
return repr(canvas)
# --- Helper methods
# -------------------------------------------------------------------------
def _check_canvas(self):
"""Check if canvas has been initialized."""
if self._canvas is None:
raise Exception('Must create the canvas first!')
def _check_point(self, x_coord, y_coord):
"""
Check that point is a positive integer located within the canvas.
If the coordinates are not posistve integers an exception is raised.
If the coordinates are outside the canvas a warning is emitted.
"""
if not (isinstance(x_coord, int) and x_coord > 0 and
isinstance(x_coord, int) and y_coord > 0):
raise TypeError('Point ({}, {}) must have positive integer '
'coordinates!'.format(x_coord, y_coord))
check_x = x_coord >= 1 and x_coord <= self._width
check_y = y_coord >= 1 and y_coord <= self._height
check = check_x and check_y
if not check:
raise ValueError('Point ({}, {}) is outside of canvas!'
''.format(x_coord, y_coord))
return check
def _draw_hline(self, x1, x2, y):
"""Draw horizontal line on canvas on 0-based indexing."""
self._canvas[y][x1:x2+1] = [self.MARKER]*(x2-x1+1)
def _draw_vline(self, y1, y2, x):
"""Draw vertical line on canvas on 0-based indexing."""
for y in range(y1, y2+1):
self._canvas[y][x] = self.MARKER
def _fill_helper(self, queue, color, x_i, y_i, dx=0, dy=0):
"""
Check pixel located at xi+dx and yi+dy for color and add to fill queue.
"""
x_dx, y_dy = x_i + dx, y_i + dy
value = self._canvas[y_dy][x_dx]
if value != color and value != self.MARKER:
self._canvas[y_dy][x_dx] = color
queue.append((x_dx, y_dy))
# --- Canvas API methods
# -------------------------------------------------------------------------
[docs] def create(self, width, height):
"""Create the canvas with a given width and height."""
# Check input
if not (isinstance(width, int) and width > 0 and
isinstance(height, int) and height > 0):
raise TypeError("Must provide a positive integer for the width "
" and the height!")
self._width = width
self._height = height
# Top and bottom canvas boder used for printing
self._top_bottom = self.HBORDER*(width + 2)
self._canvas = [[None]*width for item in range(height)]
[docs] def draw_line(self, x1, y1, x2, y2):
"""Draw horizontal or vertical line on canvas on 1-based indexing."""
self._check_canvas()
if self._check_point(x1, y1) and self._check_point(x2, y2):
if x1 != x2 and y1 != y2:
raise ValueError('Only horizontal or vertical lines are '
'currently supported')
upper_left, bottom_right = sorted([(x1, y1), (x2, y2)])
if x1 == x2:
self._draw_vline(upper_left[1]-1, bottom_right[1]-1, x1-1)
elif y1 == y2:
self._draw_hline(upper_left[0]-1, bottom_right[0]-1, y1-1)
[docs] def draw_rectangle(self, x1, y1, x2, y2):
"""Draw rectangle on canvas on 1-based indexing."""
self._check_canvas()
if self._check_point(x1, y1) and self._check_point(x1, y1):
self.draw_line(x1, y1, x2, y1)
self.draw_line(x1, y2, x2, y2)
self.draw_line(x1, y1, x1, y2)
self.draw_line(x2, y1, x2, y2)
[docs] def fill(self, x, y, color=DEFAULT_COLOR):
"""
Fill the entire area connected to `x` and `y` with `color`.
Notes
-----
- This method uses the "Forest Fire" algorithm. Could be improved,
but should suffice for this application.
"""
self._check_canvas()
color = check_color(color)
if self._check_point(x, y):
x_o, y_o = x - 1, y - 1
value = self._canvas[y_o][x_o]
if value != color and value != self.MARKER:
queue = deque() # Using deque as Queue as a convenience
self._fill_helper(queue, color, x_o, y_o)
while queue:
node = queue.pop()
x_i, y_i = node
if x_i - 1 > -1:
self._fill_helper(queue, color, x_i, y_i, dx=-1)
if y_i - 1 > -1:
self._fill_helper(queue, color, x_i, y_i, dy=-1)
if x_i + 1 < self._width:
self._fill_helper(queue, color, x_i, y_i, dx=1)
if y_i + 1 < self._height:
self._fill_helper(queue, color, x_i, y_i, dy=1)
[docs] def erase(self):
"""Erase the contents of the canvas."""
self._check_canvas()
self.create(self._width, self._height)
# --- Properties
# -------------------------------------------------------------------------
@property
def data(self):
"""Return the list of list holding the canvas data values.."""
return self._canvas
@property
def width(self):
"""Return the current width of the canvas, or None if not set."""
return self._width
@property
def height(self):
"""Return the current height of the canvas, or None if not set."""
return self._height