"""The graphics module implements a simple GUI library.""" import sys import math try: import tkinter except Exception as e: print('Could not load tkinter: ' + str(e)) FRAME_TIME = 1/30 class Canvas(object): """A Canvas object supports drawing and animation primitives. draw_* methods return the id number of a shape object in the underlying Tk object. This id can be passed to move_* and edit_* methods. Canvas is a singleton; only one Canvas instance can be created. """ _instance = None def __init__(self, width=1024, height=768, title='', color='White', tk=None): # Singleton enforcement if Canvas._instance is not None: raise Exception('Only one canvas can be instantiated.') Canvas._instance = self # Attributes self.color = color self.width = width self.height = height # Root window self._tk = tk or tkinter.Tk() self._tk.protocol('WM_DELETE_WINDOW', sys.exit) self._tk.title(title or 'Graphics Window') self._tk.bind('', self._click) self._click_pos = None # Canvas object self._canvas = tkinter.Canvas(self._tk, width=width, height=height) self._canvas.pack() self._draw_background() self._canvas.update() self._images = dict() def clear(self, shape='all'): """Clear all shapes, text, and images.""" self._canvas.delete(shape) if shape == 'all': self._draw_background() self._canvas.update() def draw_polygon(self, points, color='Black', fill_color=None, filled=1, smooth=0, width=1): """Draw a polygon and return its tkinter id. points -- a list of (x, y) pairs encoding pixel positions """ if fill_color == None: fill_color = color if filled == 0: fill_color = "" return self._canvas.create_polygon(flattened(points), outline=color, fill=fill_color, smooth=smooth, width=width) def draw_circle(self, center, radius, color='Black', fill_color=None, filled=1, width=1): """Draw a cirlce and return its tkinter id. center -- an (x, y) pair encoding a pixel position """ if fill_color == None: fill_color = color if filled == 0: fill_color = "" x0, y0 = [c - radius for c in center] x1, y1 = [c + radius for c in center] return self._canvas.create_oval(x0, y0, x1, y1, outline=color, fill=fill_color, width=width) def draw_image(self, pos, image_file=None, scale=1, anchor=tkinter.NW, behind=0): """Draw an image from a file and return its tkinter id.""" key = (image_file, scale) if key not in self._images: image = tkinter.PhotoImage(file=image_file) if scale >= 1: image = image.zoom(int(scale)) else: image = image.subsample(int(1/scale)) self._images[key] = image image = self._images[key] x, y = pos id = self._canvas.create_image(x, y, image=image, anchor=anchor) if behind > 0: self._canvas.tag_lower(id, behind) return id def draw_text(self, text, pos, color='Black', font='Arial', size=12, style='normal', anchor=tkinter.NW): """Draw text and return its tkinter id.""" x, y = pos font = (font, str(size), style) return self._canvas.create_text(x, y, fill=color, text=text, font=font, anchor=anchor) def edit_text(self, id, text=None, color=None, font=None, size=12, style='normal'): """Edit the text, color, or font of an existing text object.""" if color is not None: self._canvas.itemconfigure(id, fill=color) if text is not None: self._canvas.itemconfigure(id, text=text) if font is not None: self._canvas.itemconfigure(id, font=(font, str(size), style)) def animate_shape(self, id, duration, points_fn, frame_count=0): """Animate an existing shape over points.""" max_frames = duration // FRAME_TIME points = points_fn(frame_count) self._canvas.coords(id, flattened(points)) if frame_count < max_frames: def tail(): """Continues the animation at the next frame.""" self.animate_shape(id, duration, points_fn, frame_count + 1) self._tk.after(int(FRAME_TIME * 1000), tail) def slide_shape(self, id, end_pos, duration, elapsed=0): """Slide an existing shape to end_pos.""" points = paired(self._canvas.coords(id)) start_pos = points[0] max_frames = duration // FRAME_TIME def points_fn(frame_count): completed = frame_count / max_frames offset = [(e - s) * completed for s, e in zip(start_pos, end_pos)] return [shift_point(p, offset) for p in points] self.animate_shape(id, duration, points_fn) def wait_for_click(self, seconds=0): """Return (position, elapsed) pair of click position and elapsed time. position: (x,y) pixel position of click elapsed: milliseconds elapsed since call seconds: maximum number of seconds to wait for a click If there is still no click after the given time, return (None, seconds). """ elapsed = 0 while elapsed < seconds or seconds == 0: if self._click_pos is not None: pos = self._click_pos self._click_pos = None return pos, elapsed self._sleep(FRAME_TIME) elapsed += FRAME_TIME return None, elapsed def _draw_background(self): w, h = self.width - 1, self.height - 1 corners = [(0,0), (0, h), (w, h), (w, 0)] self.draw_polygon(corners, self.color, fill_color=self.color, filled=True, smooth=False) def _click(self, event): self._click_pos = (event.x, event.y) def _sleep(self, seconds): self._tk.update_idletasks() self._tk.after(int(1000 * seconds), self._tk.quit) self._tk.mainloop() def flattened(points): """Return a flat list of coordinates from a list of pairs.""" coords = list() [coords.extend(p) for p in points] return tuple(coords) def paired(coords): """Return a list of pairs from a flat list of coordinates.""" assert len(coords) % 2 == 0, 'Coordinates are not paired.' points = [] x = None for elem in coords: if x is None: x = elem else: points.append((x, elem)) x = None return points def translate_point(point, angle, distance): """Translate a point a distance in a direction (angle).""" x, y = point return (x + math.cos(angle) * distance, y + math.sin(angle) * distance) def shift_point(point, offset): """Shift a point by an offset.""" x, y = point dx, dy = offset return (x + dx, y + dy) def rectangle_points(pos, width, height): """Return the points of a rectangle starting at pos.""" x1, y1 = pos x2, y2 = width + x1, height + y1 return [(x1, y1), (x1, y2), (x2, y2), (x2, y1)] def format_color(r, g, b): """Format a color as a string. r, g, b -- integers from 0 to 255 """ return '#{0:02x}{1:02x}{2:02x}'.format(int(r * 255), int(g * 255), int(b * 255))