diff options
author | florent <florent.pigout@gmail.com> | 2011-04-28 09:34:59 (GMT) |
---|---|---|
committer | florent <florent.pigout@gmail.com> | 2011-04-28 09:34:59 (GMT) |
commit | 10376e6aa0eb8221ae61981a56df18dde0b84d5b (patch) | |
tree | a50da7ac1edca4191241ddceeae6a9f90c806287 | |
parent | 05ac644c99c1ea7ea3b9ade7e6f799d955aec049 (diff) |
manage graphics in a nicer way - lets see for dnd feature now\!
-rw-r--r-- | activity.py | 2 | ||||
-rw-r--r-- | atoidejouer/ui/screens.py | 295 | ||||
-rw-r--r-- | lib/__init__.py | 0 | ||||
-rw-r--r-- | lib/euclid.py | 516 | ||||
-rw-r--r-- | lib/graphics.py | 1681 | ||||
-rw-r--r-- | lib/proximity.py | 88 | ||||
-rw-r--r-- | lib/pytweener.py | 343 |
7 files changed, 2802 insertions, 123 deletions
diff --git a/activity.py b/activity.py index 518b1e3..094dcab 100644 --- a/activity.py +++ b/activity.py @@ -147,7 +147,7 @@ class AToiDeJouerActivity(activity.Activity): # add tool bars self.set_toolbox(self._toolbox) # ... - _rate = 0.5 + _rate = 0.25 # sequence dict and list self.graphic_keys = StoryKeys('graphics', rate=_rate) self.sound_keys = StoryKeys('sounds', rate=_rate) diff --git a/atoidejouer/ui/screens.py b/atoidejouer/ui/screens.py index f7ad2e5..044da17 100644 --- a/atoidejouer/ui/screens.py +++ b/atoidejouer/ui/screens.py @@ -9,6 +9,9 @@ from gettext import gettext as _ # gtk import import cairo, gobject, gtk, glib +# graphics import +from lib import graphics + # sugar import from sugar.activity import activity from sugar.graphics.objectchooser import ObjectChooser @@ -38,21 +41,39 @@ def _on_image_clicked(screen, widget, sequence_name): screen.timeline.set_active_sequence(sequence_name) -class ScreenStory(gtk.Frame): # gtk.Fixed +def _on_drag_finish(image, scene, keys, size): + # _align = self._get_keys('graphics').set_align(sequence_name, + # frame, _current) + # DEBUG + logger.debug('[screens] _on_drag_finish - image.x: %s' % image.x) + logger.debug('[screens] _on_drag_finish - image.y: %s' % image.y) + # DEBUG + # parse size + _w , _h = size + # compute align + _x = float(image.x) / _w + _y = float(image.x) / _h + # DEBUG + logger.debug('[screens] _on_drag_finish - _x: %s' % _x) + logger.debug('[screens] _on_drag_finish - _y: %s' % _y) + # DEBUG + # TODO - compute new align + + +class ScreenStory(graphics.Scene): def __init__(self, toolbar, height_offset=0, width_offset=0, set_canvas=False, frame=None): - # init parent - gtk.Frame.__init__(self) - self.set_shadow_type(gtk.SHADOW_NONE) + # .. + graphics.Scene.__init__(self) + self.background_color = "#ffffff" # keep toolbar self.toolbar = toolbar # .. self._set_canvas = set_canvas - # init frame dict - self.__frame_dict = dict() # init image dicts self.__sizes = dict() + self.__graphics = dict() self.__sounds = dict() # init fullscreen flag self.fullscreen = False @@ -68,15 +89,6 @@ class ScreenStory(gtk.Frame): # gtk.Fixed # and show self._show() - def __init_buffer(self): - # .. - _buffer = gtk.Fixed() - # set has window for bg color issue - _buffer.set_has_window(True) - _buffer.modify_bg(gtk.STATE_NORMAL, COLOR_WHITE) - # .. - return _buffer - def _get_keys(self, type_): # .. if type_ == 'graphics': @@ -100,11 +112,7 @@ class ScreenStory(gtk.Frame): # gtk.Fixed def _check_clear(self): # need clear if self._get_keys('graphics').get_clear(self.toolbar.name) is True: - # destroy all - for _d_area in self.__frame_dict.values(): - _d_area.destroy() - # ... - self.__frame_dict = dict() + # .. self.__sizes = dict() # done self._get_keys('graphics').set_clear(self.toolbar.name, False) @@ -115,14 +123,42 @@ class ScreenStory(gtk.Frame): # gtk.Fixed def refresh(self): # get the current frame - _frame = 0 if self.toolbar.activity._thread is None\ + _time = 0.0 if self.toolbar.activity._thread is None\ else self.toolbar.activity._thread._time + # get the current frame + _rate = 1.0 if self.toolbar.activity._thread is None\ + else self.toolbar.activity._thread._rate # clear ?? - if self._check_clear() is True\ - or _frame not in self.__frame_dict: - self.__buffer_update(_frame) - elif _frame in self.__frame_dict: - self.__set_child(self.__frame_dict[_frame]) + self._check_clear() + # .. + self._refresh_screen_size() + # .. + _currents = list() + # for each images + for _n in self._get_keys('graphics').get_names(): + # do move + _currents.append(self.__refresh_image(_n, _time, _rate)) + # draw mask + _currents.append(self.__refresh_default('mask')) + # DEBUG + logger.debug('[screens] refresh - _currents: %s' % _currents) + # DEBUG + # .. + # self.queue_draw() + # TODO - hide previous + for _code, _image in self.__graphics.items(): + if _code in _currents: + continue + else: + # DEBUG + logger.debug('[screens] refresh - not in _code: %s' % _code) + # DEBUG + _image.visible = False + self.queue_draw() + # show first + if self._fullscreen_changed is True: + # disable changed flag + self._fullscreen_changed = False else: pass # refresh sounds if playing @@ -132,7 +168,7 @@ class ScreenStory(gtk.Frame): # gtk.Fixed # for each sound for _n in self._get_keys('sounds').get_names(): # do move - self.__refresh_sound(_n, _frame) + self.__refresh_sound(_n, _time) else: # stop all for _code, _sound in self.__sounds.items(): @@ -141,64 +177,9 @@ class ScreenStory(gtk.Frame): # gtk.Fixed else: _sound.pause() - def __set_child(self, child): - # remove previous - _previous = self.get_child() - if _previous is None: - pass - else: - self.remove(_previous) - # do show - child.show() - # set new - self.add(child) - - def __expose(self, drawing_area, event, frame): - # .. - if drawing_area.window is None: - pass - else: - # .. - _context = drawing_area.window.cairo_create() - # init rec - _rec = self.get_allocation() - # paint it white - _context.rectangle(0, 0, _rec.width, _rec.height) - _context.set_source_rgb(1, 1, 1) - _context.paint() - # for each images - for _n in self._get_keys('graphics').get_names(): - # do move - self.__refresh_image(_n, frame, _context, _rec) - # draw mask - self.__refresh_default('mask', _context, _rec) - # clean - if frame in self.__frame_dict: - del self.__frame_dict[frame] - else: - pass - # update dict - self.__frame_dict[frame] = drawing_area - # show first - if self._fullscreen_changed is True: - # disable changed flag - self._fullscreen_changed = False - else: - pass - - def __buffer_update(self, frame): - # screen size first - self._refresh_screen_size() - # - _drawing_area = gtk.DrawingArea() - _drawing_area.connect("expose-event", self.__expose, frame) - # .. - self.__set_child(_drawing_area) - def _update_w_h(self, filename): if filename in self.__sizes: - # get image size - return self.__sizes[filename] + pass else: # get graphics path _path = storage.get_image_path(filename) @@ -214,11 +195,14 @@ class ScreenStory(gtk.Frame): # gtk.Fixed _new_w = _w _new_h = _h # update size - self.__sizes[filename] = _new_w, _new_h - # return new values - return _new_w, _new_h + self.__sizes[filename] = _new_w, _new_h, _w, _h + # return new values + return self.__sizes[filename] - def _update_x_y(self, width, height, x, y): + def _update_x_y(self, size, align): + # .. + _new_w, _new_h, _w, _h = size + _x, _y = align # if self.fullscreen is True\ or self._screen_width < 1024: @@ -228,55 +212,122 @@ class ScreenStory(gtk.Frame): # gtk.Fixed _scene_with = 1024 _scene_height = 516 # update x - _x = ((self._screen_width - width) / 2) - _x += int(x * _scene_with / 100.0) + _new_x = ((self._screen_width - _new_w) / 2) + _new_x += int(_x * _scene_with / 100.0) # update y - _y = ((self._screen_height - height - 88) / 2) - _y += int(y * _scene_height / 100.0) + _new_y = ((self._screen_height - _new_h - 88) / 2) + _new_y += int(_y * _scene_height / 100.0) # return it - return _x, _y + return _new_x, _new_y - def __refresh_default(self, default_name, context, rec): - # _code = '__%s' % default_name - _filename = '%s_default' % default_name - # get/update width and height - _w, _h = self._update_w_h(_filename) - # update x, y - _x, _y = self._update_x_y(_w, _h, 0, 0) - # .. - self.__update_drawing_area(context, rec, _filename, - (_w, _h), (_x, _y)) + def __refresh_default(self, default_name): + _code = '__%s' % default_name + # ensure current image + if _code in self.__graphics: + # .. + self.__graphics[_code].visible = True + else: + _filename = '%s_default' % default_name + # .. + _align = (0, 0) + # get/update width and height + _size = self._update_w_h(_filename) + # .. + self.__graphics[_code] =\ + self.__update_drawing_area(_filename, _size, _align) + # and return displayed code + return _code + + def __get_transition_align(self, sequence_name, filename, time, rate): + # get align + _x, _y = self._get_keys('graphics').get_align(sequence_name, + int(time), filename) + # get transition ratio + _ratio = time - int(time) + # DEBUG + logger.debug('[screens] __compute_transition_align - _ratio: %s' % _ratio) + # DEBUG + if _ratio < rate: + return _x, _y + else: + _next_x, _next_y = self._get_keys('graphics').get_align(sequence_name, + int(time) + 1, filename) + # .. + return _x + (_next_x - _x) * _ratio, _y + (_next_y - _y) * _ratio - def __refresh_image(self, sequence_name, frame, context, rec): + def __refresh_image(self, sequence_name, time, rate): # get sequence filename _current, _f_type, _k_type = self._get_keys('graphics').get_current( - sequence_name, frame) + sequence_name, int(time)) # need current if _current is None: - return + return None else: - # get align - _p_align = self._get_keys('graphics').get_align(sequence_name, - frame, _current) - # get/update width and height - _w, _h = self._update_w_h(_current) + pass + # get sequence file codes + _code = self._get_keys('graphics').get_code( + self.toolbar.name, sequence_name, _current) + # DEBUG + logger.debug('[screens] __refresh_image - _code: %s' % _code) + # DEBUG + # current file code + _code = key.random_str() if _code is None else _code + # get/update width and height + _size = self._update_w_h(_current) + # get align + _align = self.__get_transition_align(sequence_name, _current, + time, rate) + # ensure current image + if _code in self.__graphics: + # .. + _image = self.__graphics[_code] # update x, y - _x, _y = self._update_x_y(_w, _h, *_p_align) + _image.x, _image.y = self._update_x_y(_size, _align) + # get z_order + _image.z_order = self._get_keys('graphics').get_layout( + sequence_name) + # .. + _image.visible = True + else: + # .. + _image = self.__update_drawing_area(_current, _size, _align, + sequence_name=sequence_name, draggable=True) # .. - self.__update_drawing_area(context, rec, _current, - (_w, _h), (_x, _y)) + self.__graphics[_code] = _image + # .. + self._get_keys('graphics').set_code(self.toolbar.name, sequence_name, + _current, _code) + # and return displayed code + return _code - def __update_drawing_area(self, context, rec, filename, size, align): + def __update_drawing_area(self, filename, size, align, sequence_name=None, + draggable=False): # get path _path = storage.get_image_path(filename) - # split size - _w, _h = size - # init pixbuf - _pixbuf = image.get_pixbuf(_path, _w, _h, use_max=True) - # add source - context.set_source_pixbuf(_pixbuf, *align) - # paint context - context.paint_with_alpha(1) + # update x, y + _x, _y = self._update_x_y(size, align) + # scale + _new_w, _new_h, _w, _h = size + # .. + _scale = float(_new_w)/_w + # _scale_y = float(_new_h)/_h + # get z_order + if sequence_name is None: + _z_order = 1000 + else: + _z_order = self._get_keys('graphics').get_layout(sequence_name) + # image - TODO - draggable = True + _image = graphics.Image(_path, x=_x, y=_y, scale_x=_scale, + scale_y=_scale, z_order=_z_order, draggable=draggable) + # connect drag + if self._set_canvas is True: + pass + else: + _image.connect('on-drag-finish', _on_drag_finish, self, (_w, _h)) + # do add + self.add_child(_image) + # .. + return _image def __stop_unused_sounds(self, sequence_codes, current_code): # hide previous diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/__init__.py diff --git a/lib/euclid.py b/lib/euclid.py new file mode 100644 index 0000000..b3834d6 --- /dev/null +++ b/lib/euclid.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python +# +# euclid graphics maths module +# +# Copyright (c) 2006 Alex Holkner +# Alex.Holkner@mail.google.com +# +# This library is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation; either version 2.1 of the License, or (at your +# option) any later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +'''euclid graphics maths module + +Documentation and tests are included in the file "euclid.txt", or online +at http://code.google.com/p/pyeuclid +''' + +__docformat__ = 'restructuredtext' +__version__ = '$Id$' +__revision__ = '$Revision$' + +import math +import operator +import types + + + +class Vector2(object): + __slots__ = ['x', 'y'] + + def __init__(self, x=0, y=0): + self.x = x + self.y = y + + def __copy__(self): + return self.__class__(self.x, self.y) + + copy = __copy__ + + def __repr__(self): + return 'Vector2(%.2f, %.2f)' % (self.x, self.y) + + def __eq__(self, other): + if not other: return False + + if isinstance(other, Vector2): + return self.x == other.x and \ + self.y == other.y + else: + if hasattr(other, '__len__') and len(other) == 2: + return self.x == other[0] and \ + self.y == other[1] + else: + return False + + def __neq__(self, other): + return not self.__eq__(other) + + def __nonzero__(self): + return self.x != 0 or self.y != 0 + + def __len__(self): + return 2 + + def __getitem__(self, key): + return (self.x, self.y)[key] + + def __setitem__(self, key, value): + l = [self.x, self.y] + l[key] = value + self.x, self.y = l + + def __iter__(self): + return iter((self.x, self.y)) + + def __getattr__(self, name): + try: + return tuple([(self.x, self.y)['xy'.index(c)] \ + for c in name]) + except ValueError: + raise AttributeError, name + + def __add__(self, other): + return Vector2(self.x + other.x, self.y + other.y) + + __radd__ = __add__ + + def __iadd__(self, other): + self.x += other.x + self.y += other.y + return self + + def __sub__(self, other): + return Vector2(self.x - other.x, self.y - other.y) + + def __rsub__(self, other): + return Vector2(other.x - self.x, other.y - self.y) + + def __mul__(self, other): + return Vector2(self.x * other, self.y * other) + + __rmul__ = __mul__ + + def __imul__(self, other): + self.x *= other + self.y *= other + return self + + def __div__(self, other): + return Vector2(operator.div(self.x, other), + operator.div(self.y, other)) + + + def __rdiv__(self, other): + return Vector2(operator.div(other, self.x), + operator.div(other, self.y)) + + def __floordiv__(self, other): + return Vector2(operator.floordiv(self.x, other), + operator.floordiv(self.y, other)) + + + def __rfloordiv__(self, other): + return Vector2(operator.floordiv(other, self.x), + operator.floordiv(other, self.y)) + + def __truediv__(self, other): + return Vector2(operator.truediv(self.x, other), + operator.truediv(self.y, other)) + + def __rtruediv__(self, other): + return Vector2(operator.truediv(other, self.x), + operator.truediv(other, self.y)) + + def __neg__(self): + return Vector2(-self.x, -self.y) + + __pos__ = __copy__ + + def __abs__(self): + return math.sqrt(self.x * self.x + self.y * self.y) + + magnitude = __abs__ + + def magnitude_squared(self): + return self.x * self.x + self.y * self.y + + def normalize(self): + d = self.magnitude() + if d: + self.x /= d + self.y /= d + return self + + def normalized(self): + d = self.magnitude() + if d: + return Vector2(self.x / d, self.y / d) + return self.copy() + + def dot(self, other): + assert isinstance(other, Vector2) + return self.x * other.x + \ + self.y * other.y + + def cross(self): + return Vector2(self.y, -self.x) + + def product(self, v2): + # product of our vector and the other vector's perpendicular + return self.x * v2.y - self.y * v2.x + + def reflect(self, normal): + # assume normal is normalized + assert isinstance(normal, Vector2) + d = 2 * (self.x * normal.x + self.y * normal.y) + return Vector2(self.x - d * normal.x, + self.y - d * normal.y) + + def limit(self, max_magnitude): + if self.magnitude() > max_magnitude: + self.normalize() + self *= max_magnitude + + def heading(self): + return math.atan2(self.y, self.x) + + def angle(self, other): + """angle between this and the other vector in radians""" + if self == -other: # same vector facing the opposite way will kill acos on float precision + return math.pi + + return math.acos(self.normalized().dot(other.normalized())) + + +# Geometry +# Much maths thanks to Paul Bourke, http://astronomy.swin.edu.au/~pbourke +# --------------------------------------------------------------------------- + +class Geometry(object): + def _connect_unimplemented(self, other): + raise AttributeError, 'Cannot connect %s to %s' % \ + (self.__class__, other.__class__) + + def _intersect_unimplemented(self, other): + raise AttributeError, 'Cannot intersect %s and %s' % \ + (self.__class__, other.__class__) + + _intersect_point2 = _intersect_unimplemented + _intersect_line2 = _intersect_unimplemented + _intersect_circle = _intersect_unimplemented + _connect_point2 = _connect_unimplemented + _connect_line2 = _connect_unimplemented + _connect_circle = _connect_unimplemented + + + def intersect(self, other): + raise NotImplementedError + + def connect(self, other): + raise NotImplementedError + + def distance(self, other): + c = self.connect(other) + if c: + return c.length + return 0.0 + +def _intersect_point2_circle(P, C): + return (P - C.c).magnitude_squared() <= C.r * C.r + +def _intersect_line2_line2(A, B): + d = B.v.y * A.v.x - B.v.x * A.v.y + if d == 0: + return None + + dy = A.p.y - B.p.y + dx = A.p.x - B.p.x + ua = (B.v.x * dy - B.v.y * dx) / d + if not A._u_in(ua): + return None + ub = (A.v.x * dy - A.v.y * dx) / d + if not B._u_in(ub): + return None + + return Point2(A.p.x + ua * A.v.x, + A.p.y + ua * A.v.y) + +def _intersect_line2_circle(L, C): + a = L.v.magnitude_squared() + b = 2 * (L.v.x * (L.p.x - C.c.x) + \ + L.v.y * (L.p.y - C.c.y)) + c = C.c.magnitude_squared() + \ + L.p.magnitude_squared() - \ + 2 * C.c.dot(L.p) - \ + C.r * C.r + det = b * b - 4 * a * c + if det < 0: + return None + sq = math.sqrt(det) + u1 = (-b + sq) / (2 * a) + u2 = (-b - sq) / (2 * a) + if not L._u_in(u1): + u1 = max(min(u1, 1.0), 0.0) + if not L._u_in(u2): + u2 = max(min(u2, 1.0), 0.0) + + # Tangent + if u1 == u2: + return Point2(L.p.x + u1 * L.v.x, + L.p.y + u1 * L.v.y) + + return LineSegment2(Point2(L.p.x + u1 * L.v.x, + L.p.y + u1 * L.v.y), + Point2(L.p.x + u2 * L.v.x, + L.p.y + u2 * L.v.y)) + +def _connect_point2_line2(P, L): + d = L.v.magnitude_squared() + assert d != 0 + u = ((P.x - L.p.x) * L.v.x + \ + (P.y - L.p.y) * L.v.y) / d + if not L._u_in(u): + u = max(min(u, 1.0), 0.0) + return LineSegment2(P, + Point2(L.p.x + u * L.v.x, + L.p.y + u * L.v.y)) + +def _connect_point2_circle(P, C): + v = P - C.c + v.normalize() + v *= C.r + return LineSegment2(P, Point2(C.c.x + v.x, C.c.y + v.y)) + +def _connect_line2_line2(A, B): + d = B.v.y * A.v.x - B.v.x * A.v.y + if d == 0: + # Parallel, connect an endpoint with a line + if isinstance(B, Ray2) or isinstance(B, LineSegment2): + p1, p2 = _connect_point2_line2(B.p, A) + return p2, p1 + # No endpoint (or endpoint is on A), possibly choose arbitrary point + # on line. + return _connect_point2_line2(A.p, B) + + dy = A.p.y - B.p.y + dx = A.p.x - B.p.x + ua = (B.v.x * dy - B.v.y * dx) / d + if not A._u_in(ua): + ua = max(min(ua, 1.0), 0.0) + ub = (A.v.x * dy - A.v.y * dx) / d + if not B._u_in(ub): + ub = max(min(ub, 1.0), 0.0) + + return LineSegment2(Point2(A.p.x + ua * A.v.x, A.p.y + ua * A.v.y), + Point2(B.p.x + ub * B.v.x, B.p.y + ub * B.v.y)) + +def _connect_circle_line2(C, L): + d = L.v.magnitude_squared() + assert d != 0 + u = ((C.c.x - L.p.x) * L.v.x + (C.c.y - L.p.y) * L.v.y) / d + if not L._u_in(u): + u = max(min(u, 1.0), 0.0) + point = Point2(L.p.x + u * L.v.x, L.p.y + u * L.v.y) + v = (point - C.c) + v.normalize() + v *= C.r + return LineSegment2(Point2(C.c.x + v.x, C.c.y + v.y), point) + +def _connect_circle_circle(A, B): + v = B.c - A.c + v.normalize() + return LineSegment2(Point2(A.c.x + v.x * A.r, A.c.y + v.y * A.r), + Point2(B.c.x - v.x * B.r, B.c.y - v.y * B.r)) + + +class Point2(Vector2, Geometry): + def __repr__(self): + return 'Point2(%.2f, %.2f)' % (self.x, self.y) + + def intersect(self, other): + return other._intersect_point2(self) + + def _intersect_circle(self, other): + return _intersect_point2_circle(self, other) + + def connect(self, other): + return other._connect_point2(self) + + def _connect_point2(self, other): + return LineSegment2(other, self) + + def _connect_line2(self, other): + c = _connect_point2_line2(self, other) + if c: + return c._swap() + + def _connect_circle(self, other): + c = _connect_point2_circle(self, other) + if c: + return c._swap() + +class Line2(Geometry): + __slots__ = ['p', 'v'] + + def __init__(self, *args): + if len(args) == 3: + assert isinstance(args[0], Point2) and \ + isinstance(args[1], Vector2) and \ + type(args[2]) == float + self.p = args[0].copy() + self.v = args[1] * args[2] / abs(args[1]) + elif len(args) == 2: + if isinstance(args[0], Point2) and isinstance(args[1], Point2): + self.p = args[0].copy() + self.v = args[1] - args[0] + elif isinstance(args[0], Point2) and isinstance(args[1], Vector2): + self.p = args[0].copy() + self.v = args[1].copy() + else: + raise AttributeError, '%r' % (args,) + elif len(args) == 1: + if isinstance(args[0], Line2): + self.p = args[0].p.copy() + self.v = args[0].v.copy() + else: + raise AttributeError, '%r' % (args,) + else: + raise AttributeError, '%r' % (args,) + + if not self.v: + raise AttributeError, 'Line has zero-length vector' + + def __copy__(self): + return self.__class__(self.p, self.v) + + copy = __copy__ + + def __repr__(self): + return 'Line2(<%.2f, %.2f> + u<%.2f, %.2f>)' % \ + (self.p.x, self.p.y, self.v.x, self.v.y) + + p1 = property(lambda self: self.p) + p2 = property(lambda self: Point2(self.p.x + self.v.x, + self.p.y + self.v.y)) + + def _apply_transform(self, t): + self.p = t * self.p + self.v = t * self.v + + def _u_in(self, u): + return True + + def intersect(self, other): + return other._intersect_line2(self) + + def _intersect_line2(self, other): + return _intersect_line2_line2(self, other) + + def _intersect_circle(self, other): + return _intersect_line2_circle(self, other) + + def connect(self, other): + return other._connect_line2(self) + + def _connect_point2(self, other): + return _connect_point2_line2(other, self) + + def _connect_line2(self, other): + return _connect_line2_line2(other, self) + + def _connect_circle(self, other): + return _connect_circle_line2(other, self) + +class Ray2(Line2): + def __repr__(self): + return 'Ray2(<%.2f, %.2f> + u<%.2f, %.2f>)' % \ + (self.p.x, self.p.y, self.v.x, self.v.y) + + def _u_in(self, u): + return u >= 0.0 + +class LineSegment2(Line2): + def __repr__(self): + return 'LineSegment2(<%.2f, %.2f> to <%.2f, %.2f>)' % \ + (self.p.x, self.p.y, self.p.x + self.v.x, self.p.y + self.v.y) + + def _u_in(self, u): + return u >= 0.0 and u <= 1.0 + + def __abs__(self): + return abs(self.v) + + def magnitude_squared(self): + return self.v.magnitude_squared() + + def _swap(self): + # used by connect methods to switch order of points + self.p = self.p2 + self.v *= -1 + return self + + length = property(lambda self: abs(self.v)) + +class Circle(Geometry): + __slots__ = ['c', 'r'] + + def __init__(self, center, radius): + assert isinstance(center, Vector2) and type(radius) == float + self.c = center.copy() + self.r = radius + + def __copy__(self): + return self.__class__(self.c, self.r) + + copy = __copy__ + + def __repr__(self): + return 'Circle(<%.2f, %.2f>, radius=%.2f)' % \ + (self.c.x, self.c.y, self.r) + + def _apply_transform(self, t): + self.c = t * self.c + + def intersect(self, other): + return other._intersect_circle(self) + + def _intersect_point2(self, other): + return _intersect_point2_circle(other, self) + + def _intersect_line2(self, other): + return _intersect_line2_circle(other, self) + + def connect(self, other): + return other._connect_circle(self) + + def _connect_point2(self, other): + return _connect_point2_circle(other, self) + + def _connect_line2(self, other): + c = _connect_circle_line2(self, other) + if c: + return c._swap() + + def _connect_circle(self, other): + return _connect_circle_circle(other, self) diff --git a/lib/graphics.py b/lib/graphics.py new file mode 100644 index 0000000..97704f7 --- /dev/null +++ b/lib/graphics.py @@ -0,0 +1,1681 @@ +# - coding: utf-8 - + +# Copyright (C) 2008-2010 Toms Bauģis <toms.baugis at gmail.com> +# Dual licensed under the MIT or GPL Version 2 licenses. +# See http://github.com/tbaugis/hamster_experiments/blob/master/README.textile + +import math +import datetime as dt +import gtk, gobject + +import pango, cairo +import re + +try: + import pytweener +except: # we can also live without tweener. Scene.animate will not work + pytweener = None + +import colorsys +from collections import deque + +if cairo.version in ('1.8.2', '1.8.4'): + # in these two cairo versions the matrix multiplication was flipped + # http://bugs.freedesktop.org/show_bug.cgi?id=19221 + def cairo_matrix_multiply(matrix1, matrix2): + return matrix2 * matrix1 +else: + def cairo_matrix_multiply(matrix1, matrix2): + return matrix1 * matrix2 + + +class Colors(object): + hex_color_normal = re.compile("#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})") + hex_color_short = re.compile("#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])") + hex_color_long = re.compile("#([a-fA-F0-9]{4})([a-fA-F0-9]{4})([a-fA-F0-9]{4})") + + def parse(self, color): + assert color is not None + + #parse color into rgb values + if isinstance(color, basestring): + match = self.hex_color_long.match(color) + if match: + color = [int(color, 16) / 65535.0 for color in match.groups()] + else: + match = self.hex_color_normal.match(color) + if match: + color = [int(color, 16) / 255.0 for color in match.groups()] + else: + match = self.hex_color_short.match(color) + color = [int(color + color, 16) / 255.0 for color in match.groups()] + + elif isinstance(color, gtk.gdk.Color): + color = [color.red / 65535.0, + color.green / 65535.0, + color.blue / 65535.0] + + else: + # otherwise we assume we have color components in 0..255 range + if color[0] > 1 or color[1] > 1 or color[2] > 1: + color = [c / 255.0 for c in color] + + return color + + def rgb(self, color): + return [c * 255 for c in self.parse(color)] + + def gdk(self, color): + c = self.parse(color) + return gtk.gdk.Color(int(c[0] * 65535.0), int(c[1] * 65535.0), int(c[2] * 65535.0)) + + def is_light(self, color): + # tells you if color is dark or light, so you can up or down the + # scale for improved contrast + return colorsys.rgb_to_hls(*self.rgb(color))[1] > 150 + + def darker(self, color, step): + # returns color darker by step (where step is in range 0..255) + hls = colorsys.rgb_to_hls(*self.rgb(color)) + return colorsys.hls_to_rgb(hls[0], hls[1] - step, hls[2]) + + def contrast(self, color, step): + """if color is dark, will return a lighter one, otherwise darker""" + hls = colorsys.rgb_to_hls(*self.rgb(color)) + if self.is_light(color): + return colorsys.hls_to_rgb(hls[0], hls[1] - step, hls[2]) + else: + return colorsys.hls_to_rgb(hls[0], hls[1] + step, hls[2]) + # returns color darker by step (where step is in range 0..255) + +Colors = Colors() # this is a static class, so an instance will do + + +class Graphics(object): + """If context is given upon contruction, will perform drawing + operations on context instantly. Otherwise queues up the drawing + instructions and performs them in passed-in order when _draw is called + with context. + + Most of instructions are mapped to cairo functions by the same name. + Where there are differences, documenation is provided. + + See http://cairographics.org/documentation/pycairo/2/reference/context.html + for detailed description of the cairo drawing functions. + """ + def __init__(self, context = None): + self.context = context + self.colors = Colors # pointer to the color utilities instance + self.extents = None # bounds of the object, only if interactive + self.paths = None # paths for mouse hit checks + self._last_matrix = None + self.__new_instructions = [] # instruction set until it is converted into path-based instructions + self.__instruction_cache = [] + self.cache_surface = None + self._cache_layout = None + + def clear(self): + """clear all instructions""" + self.__new_instructions = [] + self.__instruction_cache = [] + self.paths = [] + + @staticmethod + def _stroke(context): context.stroke() + def stroke(self, color = None, alpha = 1): + if color or alpha < 1:self.set_color(color, alpha) + self._add_instruction(self._stroke,) + + @staticmethod + def _fill(context): context.fill() + def fill(self, color = None, alpha = 1): + if color or alpha < 1:self.set_color(color, alpha) + self._add_instruction(self._fill,) + + @staticmethod + def _mask(context, pattern): context.mask(pattern) + def mask(self, pattern): + self._add_instruction(self._mask, pattern) + + @staticmethod + def _stroke_preserve(context): context.stroke_preserve() + def stroke_preserve(self, color = None, alpha = 1): + if color or alpha < 1:self.set_color(color, alpha) + self._add_instruction(self._stroke_preserve,) + + @staticmethod + def _fill_preserve(context): context.fill_preserve() + def fill_preserve(self, color = None, alpha = 1): + if color or alpha < 1:self.set_color(color, alpha) + self._add_instruction(self._fill_preserve,) + + @staticmethod + def _new_path(context): context.new_path() + def new_path(self): + self._add_instruction(self._new_path,) + + @staticmethod + def _paint(context): context.paint() + def paint(self): + self._add_instruction(self._paint,) + + @staticmethod + def _set_font_face(context, face): context.set_font_face(face) + def set_font_face(self, face): + self._add_instruction(self._set_font_face, face) + + @staticmethod + def _set_font_size(context, size): context.set_font_size(size) + def set_font_size(self, size): + self._add_instruction(self._set_font_size, size) + + @staticmethod + def _set_source(context, image): + context.set_source(image) + def set_source(self, image, x = 0, y = 0): + self._add_instruction(self._set_source, image) + + @staticmethod + def _set_source_surface(context, surface, x, y): + context.set_source_surface(surface, x, y) + def set_source_surface(self, surface, x = 0, y = 0): + self._add_instruction(self._set_source_surface, surface, x, y) + + @staticmethod + def _set_source_pixbuf(context, pixbuf, x, y): + context.set_source_pixbuf(pixbuf, x, y) + def set_source_pixbuf(self, pixbuf, x = 0, y = 0): + self._add_instruction(self._set_source_pixbuf, pixbuf, x, y) + + @staticmethod + def _save_context(context): context.save() + def save_context(self): + self._add_instruction(self._save_context) + + @staticmethod + def _restore_context(context): context.restore() + def restore_context(self): + self._add_instruction(self._restore_context) + + + @staticmethod + def _clip(context): context.clip() + def clip(self): + self._add_instruction(self._clip) + + @staticmethod + def _translate(context, x, y): context.translate(x, y) + def translate(self, x, y): + self._add_instruction(self._translate, x, y) + + @staticmethod + def _rotate(context, radians): context.rotate(radians) + def rotate(self, radians): + self._add_instruction(self._rotate, radians) + + @staticmethod + def _move_to(context, x, y): context.move_to(x, y) + def move_to(self, x, y): + self._add_instruction(self._move_to, x, y) + + @staticmethod + def _line_to(context, x, y): context.line_to(x, y) + def line_to(self, x, y = None): + if y is not None: + self._add_instruction(self._line_to, x, y) + elif isinstance(x, list) and y is None: + for x2, y2 in x: + self._add_instruction(self._line_to, x2, y2) + + + @staticmethod + def _rel_line_to(context, x, y): context.rel_line_to(x, y) + def rel_line_to(self, x, y = None): + if x is not None and y is not None: + self._add_instruction(self._rel_line_to, x, y) + elif isinstance(x, list) and y is None: + for x2, y2 in x: + self._add_instruction(self._rel_line_to, x2, y2) + + + @staticmethod + def _curve_to(context, x, y, x2, y2, x3, y3): + context.curve_to(x, y, x2, y2, x3, y3) + def curve_to(self, x, y, x2, y2, x3, y3): + """draw a curve. (x2, y2) is the middle point of the curve""" + self._add_instruction(self._curve_to, x, y, x2, y2, x3, y3) + + @staticmethod + def _close_path(context): context.close_path() + def close_path(self): + self._add_instruction(self._close_path,) + + @staticmethod + def _set_line_width(context, width): + context.set_line_width(width) + @staticmethod + def _set_dash(context, dash, dash_offset = 0): + context.set_dash(dash, dash_offset) + + def set_line_style(self, width = None, dash = None, dash_offset = 0): + """change width and dash of a line""" + if width is not None: + self._add_instruction(self._set_line_width, width) + + if dash is not None: + self._add_instruction(self._set_dash, dash, dash_offset) + + def _set_color(self, context, r, g, b, a): + if a < 1: + context.set_source_rgba(r, g, b, a) + else: + context.set_source_rgb(r, g, b) + + def set_color(self, color, alpha = 1): + """set active color. You can use hex colors like "#aaa", or you can use + normalized RGB tripplets (where every value is in range 0..1), or + you can do the same thing in range 0..65535. + also consider skipping this operation and specify the color on stroke and + fill. + """ + color = self.colors.parse(color) # parse whatever we have there into a normalized triplet + if len(color) == 4 and alpha is None: + alpha = color[3] + r, g, b = color[:3] + self._add_instruction(self._set_color, r, g, b, alpha) + + @staticmethod + def _arc(context, x, y, radius, start_angle, end_angle): + context.arc(x, y, radius, start_angle, end_angle) + def arc(self, x, y, radius, start_angle, end_angle): + """draw arc going counter-clockwise from start_angle to end_angle""" + self._add_instruction(self._arc, x, y, radius, start_angle, end_angle) + + def circle(self, x, y, radius): + """draw circle""" + self._add_instruction(self._arc, x, y, radius, 0, math.pi * 2) + + def ellipse(self, x, y, width, height, edges = None): + """draw 'perfect' ellipse, opposed to squashed circle. works also for + equilateral polygons""" + # the automatic edge case is somewhat arbitrary + steps = edges or max((32, width, height)) / 2 + + angle = 0 + step = math.pi * 2 / steps + points = [] + while angle < math.pi * 2: + points.append((width / 2.0 * math.cos(angle), + height / 2.0 * math.sin(angle))) + angle += step + + min_x = min((point[0] for point in points)) + min_y = min((point[1] for point in points)) + + self.move_to(points[0][0] - min_x + x, points[0][1] - min_y + y) + for p_x, p_y in points: + self.line_to(p_x - min_x + x, p_y - min_y + y) + self.line_to(points[0][0] - min_x + x, points[0][1] - min_y + y) + + + @staticmethod + def _arc_negative(context, x, y, radius, start_angle, end_angle): + context.arc_negative(x, y, radius, start_angle, end_angle) + def arc_negative(self, x, y, radius, start_angle, end_angle): + """draw arc going clockwise from start_angle to end_angle""" + self._add_instruction(self._arc_negative, x, y, radius, start_angle, end_angle) + + @staticmethod + def _rounded_rectangle(context, x, y, x2, y2, corner_radius): + half_corner = corner_radius / 2 + + context.move_to(x + corner_radius, y) + context.line_to(x2 - corner_radius, y) + context.curve_to(x2 - half_corner, y, x2, y + half_corner, x2, y + corner_radius) + context.line_to(x2, y2 - corner_radius) + context.curve_to(x2, y2 - half_corner, x2 - half_corner, y2, x2 - corner_radius, y2) + context.line_to(x + corner_radius, y2) + context.curve_to(x + half_corner, y2, x, y2 - half_corner, x, y2 - corner_radius) + context.line_to(x, y + corner_radius) + context.curve_to(x, y + half_corner, x + half_corner, y, x + corner_radius, y) + + @staticmethod + def _rectangle(context, x, y, w, h): context.rectangle(x, y, w, h) + def rectangle(self, x, y, width, height, corner_radius = 0): + "draw a rectangle. if corner_radius is specified, will draw rounded corners" + if corner_radius <= 0: + self._add_instruction(self._rectangle, x, y, width, height) + return + + # make sure that w + h are larger than 2 * corner_radius + corner_radius = min(corner_radius, min(width, height) / 2) + x2, y2 = x + width, y + height + self._add_instruction(self._rounded_rectangle, x, y, x2, y2, corner_radius) + + def fill_area(self, x, y, width, height, color, opacity = 1): + """fill rectangular area with specified color""" + self.rectangle(x, y, width, height) + self.fill(color, opacity) + + + def fill_stroke(self, fill = None, stroke = None, line_width = None): + """fill and stroke the drawn area in one go""" + if line_width: self.set_line_style(line_width) + + if fill and stroke: + self.fill_preserve(fill) + elif fill: + self.fill(fill) + + if stroke: + self.stroke(stroke) + + + @staticmethod + def _show_layout(context, layout, text, font_desc, alignment, width, wrap, ellipsize): + layout.set_font_description(font_desc) + layout.set_markup(text) + layout.set_width(int(width or -1)) + layout.set_alignment(alignment) + + if width > 0: + if wrap is not None: + layout.set_wrap(wrap) + else: + layout.set_ellipsize(ellipsize or pango.ELLIPSIZE_END) + + context.show_layout(layout) + + def create_layout(self, size = None): + """utility function to create layout with the default font. Size and + alignment parameters are shortcuts to according functions of the + pango.Layout""" + if not self.context: + # TODO - this is rather sloppy as far as exception goes + # should explain better + raise "Can not create layout without existing context!" + + layout = self.context.create_layout() + font_desc = pango.FontDescription(gtk.Style().font_desc.to_string()) + if size: font_desc.set_size(size * pango.SCALE) + + layout.set_font_description(font_desc) + return layout + + + def show_label(self, text, size = None, color = None): + """display text with system's default font""" + font_desc = pango.FontDescription(gtk.Style().font_desc.to_string()) + if color: self.set_color(color) + if size: font_desc.set_size(size * pango.SCALE) + self.show_layout(text, font_desc) + + + @staticmethod + def _show_text(context, text): context.show_text(text) + def show_text(self, text): + self._add_instruction(self._show_text, text) + + @staticmethod + def _text_path(context, text): context.text_path(text) + def text_path(self, text): + """this function is most likely to change""" + self._add_instruction(self._text_path, text) + + def show_layout(self, text, font_desc, alignment = pango.ALIGN_LEFT, width = -1, wrap = None, ellipsize = None): + """display text. font_desc is string of pango font description + often handier than calling this function directly, is to create + a class:Label object + """ + layout = self._cache_layout = self._cache_layout or gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0))).create_layout() + self._add_instruction(self._show_layout, layout, text, font_desc, alignment, width, wrap, ellipsize) + + def _add_instruction(self, function, *params): + if self.context: + function(self.context, *params) + else: + self.paths = None + self.__new_instructions.append((function, params)) + + + def _draw(self, context, opacity): + """draw accumulated instructions in context""" + + # if we have been moved around, we should update bounds + fresh_draw = self.__new_instructions and len(self.__new_instructions) > 0 + if fresh_draw: #new stuff! + self.paths = [] + self.__instruction_cache = self.__new_instructions + self.__new_instructions = [] + else: + if not self.__instruction_cache: + return + + for instruction, args in self.__instruction_cache: + if fresh_draw and instruction in (self._new_path, self._stroke, self._fill, self._clip): + self.paths.append(context.copy_path()) + + if opacity < 1 and instruction == self._set_color: + self._set_color(context, args[0], args[1], args[2], args[3] * opacity) + elif opacity < 1 and instruction == self._paint: + context.paint_with_alpha(opacity) + else: + instruction(context, *args) + + + + def _draw_as_bitmap(self, context, opacity): + """ + instead of caching paths, this function caches the whole drawn thing + use cache_as_bitmap on sprite to enable this mode + """ + matrix = context.get_matrix() + matrix_changed = matrix != self._last_matrix + new_instructions = len(self.__new_instructions) > 0 + + if new_instructions or matrix_changed: + if new_instructions: + self.__instruction_cache = list(self.__new_instructions) + self.__new_instructions = deque() + + self.paths = deque() + self.extents = None + + if not self.__instruction_cache: + # no instructions - nothing to do + return + + # instructions that end path + path_end_instructions = (self._new_path, self._clip, self._stroke, self._fill, self._stroke_preserve, self._fill_preserve) + + # measure the path extents so we know the size of cache surface + # also to save some time use the context to paint for the first time + extents = gtk.gdk.Rectangle() + for instruction, args in self.__instruction_cache: + if instruction in path_end_instructions: + self.paths.append(context.copy_path()) + exts = context.path_extents() + exts = gtk.gdk.Rectangle(int(exts[0]), int(exts[1]), + int(exts[2]-exts[0]), int(exts[3]-exts[1])) + if extents.width and extents.height: + extents = extents.union(exts) + else: + extents = exts + + + if instruction in (self._set_source_pixbuf, self._set_source_surface): + # draw a rectangle around the pathless instructions so that the extents are correct + pixbuf = args[0] + x = args[1] if len(args) > 1 else 0 + y = args[2] if len(args) > 2 else 0 + self._rectangle(context, x, y, pixbuf.get_width(), pixbuf.get_height()) + self._clip() + + if instruction == self._paint and opacity < 1: + context.paint_with_alpha(opacity) + elif instruction == self._set_color and opacity < 1: + self._set_color(context, args[0], args[1], args[2], args[3] * opacity) + else: + instruction(context, *args) + + + # avoid re-caching if we have just moved + just_transforms = new_instructions == False and \ + matrix and self._last_matrix \ + and all([matrix[i] == self._last_matrix[i] for i in range(4)]) + + # TODO - this does not look awfully safe + extents.x += matrix[4] + extents.y += matrix[5] + self.extents = extents + + if not just_transforms: + # now draw the instructions on the caching surface + w = int(extents.width) + 1 + h = int(extents.height) + 1 + self.cache_surface = context.get_target().create_similar(cairo.CONTENT_COLOR_ALPHA, w, h) + ctx = gtk.gdk.CairoContext(cairo.Context(self.cache_surface)) + ctx.translate(-extents.x, -extents.y) + + ctx.transform(matrix) + for instruction, args in self.__instruction_cache: + instruction(ctx, *args) + + self._last_matrix = matrix + else: + context.save() + context.identity_matrix() + context.translate(self.extents.x, self.extents.y) + context.set_source_surface(self.cache_surface) + if opacity < 1: + context.paint_with_alpha(opacity) + else: + context.paint() + context.restore() + + + + + +class Sprite(gtk.Object): + """The Sprite class is a basic display list building block: a display list + node that can display graphics and can also contain children. + Once you have created the sprite, use Scene's add_child to add it to + scene + """ + + __gsignals__ = { + "on-mouse-over": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), + "on-mouse-out": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), + "on-mouse-down": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-mouse-up": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-click": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-drag-start": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-drag": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-drag-finish": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-render": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()) + } + + transformation_attrs = set(('x', 'y', 'rotation', 'scale_x', 'scale_y', 'pivot_x', 'pivot_y')) + + visibility_attrs = set(('opacity', 'visible', 'z_order')) + + cache_attrs = set(('_stroke_context', '_matrix', '_prev_parent_matrix', '_extents', '_scene')) + + graphics_unrelated_attrs = set(('drag_x', 'drag_y', 'sprites', 'mouse_cursor', '_sprite_dirty')) + + + + def __init__(self, x = 0, y = 0, + opacity = 1, visible = True, + rotation = 0, pivot_x = 0, pivot_y = 0, + scale_x = 1, scale_y = 1, + interactive = False, draggable = False, + z_order = 0, mouse_cursor = None, + cache_as_bitmap = False, snap_to_pixel = True): + gtk.Object.__init__(self) + + self._scene = None + + #: list of children sprites. Use :func:`add_child` to add sprites + self.sprites = [] + + #: instance of :ref:`graphics` for this sprite + self.graphics = Graphics() + + #: boolean denoting whether the sprite responds to mouse events + self.interactive = interactive + + #: boolean marking if sprite can be automatically dragged + self.draggable = draggable + + #: relative x coordinate of the sprites' rotation point + self.pivot_x = pivot_x + + #: relative y coordinates of the sprites' rotation point + self.pivot_y = pivot_y + + #: sprite opacity + self.opacity = opacity + + #: boolean visibility flag + self.visible = visible + + #: pointer to parent :class:`Sprite` or :class:`Scene` + self.parent = None + + #: sprite coordinates + self.x, self.y = x, y + + #: rotation of the sprite in radians (use :func:`math.degrees` to convert to degrees if necessary) + self.rotation = rotation + + #: scale X + self.scale_x = scale_x + + #: scale Y + self.scale_y = scale_y + + #: drawing order between siblings. The one with the highest z_order will be on top. + self.z_order = z_order + + #: mouse-over cursor of the sprite. See :meth:`Scene.mouse_cursor` + #: for possible values + self.mouse_cursor = mouse_cursor + + #: x position of the cursor within mouse upon drag. change this value + #: in on-drag-start to adjust drag point + self.drag_x = 0 + + #: y position of the cursor within mouse upon drag. change this value + #: in on-drag-start to adjust drag point + self.drag_y = 0 + + #: Whether the sprite should be cached as a bitmap. Default: true + #: Generally good when you have many static sprites + self.cache_as_bitmap = cache_as_bitmap + + #: Should the sprite coordinates always rounded to full pixel. Default: true + #: Mostly this is good for performance but in some cases that can lead + #: to rounding errors in positioning. + self.snap_to_pixel = snap_to_pixel + + self.__dict__["_sprite_dirty"] = True # flag that indicates that the graphics object of the sprite should be rendered + self.__dict__["_sprite_moved"] = True # flag that indicates that the graphics object of the sprite should be rendered + + self._matrix = None + self._prev_parent_matrix = None + + self._extents = None + self._prev_extents = None + self._stroke_context = None + + + def __setattr__(self, name, val): + if self.__dict__.get(name, "hamster_graphics_no_value_really") == val: + return + self.__dict__[name] = val + + # prev parent matrix walks downwards + if name == '_prev_parent_matrix' and self.visible: + self._extents = None + + # downwards recursive invalidation of parent matrix + for sprite in self.sprites: + sprite._prev_parent_matrix = None + + + if name in self.cache_attrs or name in self.graphics_unrelated_attrs: + return + + + """all the other changes influence cache vars""" + + # either transforms or path operations - extents have to be recalculated + self._extents = None + + if name == 'visible' and self.visible == False: + # when transforms happen while sprite is invisible + for sprite in self.sprites: + sprite._prev_parent_matrix = None + + + # on moves invalidate our matrix, child extent cache (as that depends on our transforms) + # as well as our parent's child extents as we moved + # then go into children and invalidate the parent matrix down the tree + if name in self.transformation_attrs: + self._matrix = None + for sprite in self.sprites: + sprite._prev_parent_matrix = None + + # if attribute is not in transformation nor visibility, we conclude + # that it must be causing the sprite needs re-rendering + if name not in self.transformation_attrs and name not in self.visibility_attrs: + self.__dict__["_sprite_dirty"] = True + + # on parent change invalidate the matrix + if name == 'parent': + self._prev_parent_matrix = None + return + + if name == 'opacity' and self.__dict__.get("cache_as_bitmap") and hasattr(self, "graphics"): + # invalidating cache for the bitmap version as that paints opacity in the image + self.graphics._last_matrix = None + + if name == 'z_order' and self.__dict__.get('parent'): + self.parent._sort() + + + self.redraw() + + + def _sort(self): + """sort sprites by z_order""" + self.sprites = sorted(self.sprites, key=lambda sprite:sprite.z_order) + + def add_child(self, *sprites): + """Add child sprite. Child will be nested within parent""" + for sprite in sprites: + if sprite == self: + raise Exception("trying to add sprite to itself") + if sprite.parent: + sprite.x, sprite.y = self.from_scene_coords(*sprite.to_scene_coords()) + sprite.parent.remove_child(sprite) + + self.sprites.append(sprite) + sprite.parent = self + self._sort() + + + def remove_child(self, *sprites): + for sprite in sprites: + self.sprites.remove(sprite) + sprite._scene = None + sprite.parent = None + + def bring_to_front(self): + """adjusts sprite's z-order so that the sprite is on top of it's + siblings""" + if not self.parent: + return + self.z_order = self.parent.sprites[-1].z_order + 1 + + def send_to_back(self): + """adjusts sprite's z-order so that the sprite is behind it's + siblings""" + if not self.parent: + return + self.z_order = self.parent.sprites[0].z_order - 1 + + + def get_extents(self): + """measure the extents of the sprite's graphics. if context is provided + will use that to draw the paths""" + if self._extents: + return self._extents + + + if self._sprite_dirty: + # redrawing merely because we need fresh extents of the sprite + context = gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0))) + context.transform(self.get_matrix()) + self.emit("on-render") + self.__dict__["_sprite_dirty"] = False + self.graphics._draw(context, 1) + + + if not self.graphics.paths: + self.graphics._draw(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0)), 1) + + if not self.graphics.paths: + return None + + context = gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0))) + context.transform(self.get_matrix()) + + for path in self.graphics.paths: + context.append_path(path) + context.identity_matrix() + + ext = context.path_extents() + ext = gtk.gdk.Rectangle(int(ext[0]), int(ext[1]), + int(ext[2] - ext[0]), int(ext[3] - ext[1])) + + if not ext.width and not ext.height: + ext = None + + self.__dict__['_extents'] = ext + self.__dict__['_stroke_context'] = context + + return ext + + + def check_hit(self, x, y): + """check if the given coordinates are inside the sprite's fill or stroke + path""" + + extents = self.get_extents() + + if not extents: + return False + + if extents.x <= x <= extents.x + extents.width and extents.y <= y <= extents.y + extents.height: + return self._stroke_context is None or self._stroke_context.in_fill(x, y) + else: + return False + + def get_scene(self): + """returns class:`Scene` the sprite belongs to""" + if not self._scene: + if hasattr(self, 'parent') and self.parent: + if isinstance(self.parent, Sprite) == False: + scene = self.parent + else: + scene = self.parent.get_scene() + + self._scene = scene + + return self._scene + + def redraw(self): + """queue redraw of the sprite. this function is called automatically + whenever a sprite attribute changes. sprite changes that happen + during scene redraw are ignored in order to avoid echoes. + Call scene.redraw() explicitly if you need to redraw in these cases. + """ + scene = self.get_scene() + if scene and scene._redraw_in_progress == False and self.parent: + self.parent.redraw() + + def animate(self, duration = None, easing = None, on_complete = None, on_update = None, **kwargs): + """Request paretn Scene to Interpolate attributes using the internal tweener. + Specify sprite's attributes that need changing. + `duration` defaults to 0.4 seconds and `easing` to cubic in-out + (for others see pytweener.Easing class). + + Example:: + # tween some_sprite to coordinates (50,100) using default duration and easing + self.animate(x = 50, y = 100) + """ + scene = self.get_scene() + if scene: + scene.animate(self, duration, easing, on_complete, on_update, **kwargs) + else: + for key, val in kwargs.items(): + setattr(self, key, val) + + def get_local_matrix(self): + if not self._matrix: + self._matrix = cairo.Matrix() + + if self.snap_to_pixel: + self._matrix.translate(int(self.x) + int(self.pivot_x), int(self.y) + int(self.pivot_y)) + else: + self._matrix.translate(self.x + self.pivot_x, self.y + self.pivot_y) + + if self.rotation: + self._matrix.rotate(self.rotation) + + + if self.snap_to_pixel: + self._matrix.translate(int(-self.pivot_x), int(-self.pivot_y)) + else: + self._matrix.translate(-self.pivot_x, -self.pivot_y) + + + if self.scale_x != 1 or self.scale_y != 1: + self._matrix.scale(self.scale_x, self.scale_y) + + return cairo.Matrix() * self._matrix + + + def get_matrix(self): + """return sprite's current transformation matrix""" + if self.parent: + return cairo_matrix_multiply(self.get_local_matrix(), + (self._prev_parent_matrix or self.parent.get_matrix())) + else: + return self.get_local_matrix() + + + def from_scene_coords(self, x=0, y=0): + """Converts x, y given in the scene coordinates to sprite's local ones + coordinates""" + matrix = self.get_matrix() + matrix.invert() + return matrix.transform_point(x, y) + + def to_scene_coords(self, x=0, y=0): + """Converts x, y from sprite's local coordinates to scene coordinates""" + return self.get_matrix().transform_point(x, y) + + def _draw(self, context, opacity = 1, parent_matrix = None): + if self.visible is False: + return + + if (self._sprite_dirty): # send signal to redo the drawing when sprite is dirty + self.__dict__['_extents'] = None + self.emit("on-render") + self.__dict__["_sprite_dirty"] = False + + + parent_matrix = parent_matrix or cairo.Matrix() + + # cache parent matrix + self._prev_parent_matrix = parent_matrix + + matrix = self.get_local_matrix() + + context.save() + context.transform(matrix) + + + if self.cache_as_bitmap: + self.graphics._draw_as_bitmap(context, self.opacity * opacity) + else: + self.graphics._draw(context, self.opacity * opacity) + + self.__dict__['_prev_extents'] = self._extents or self.get_extents() + + for sprite in self.sprites: + sprite._draw(context, self.opacity * opacity, cairo_matrix_multiply(matrix, parent_matrix)) + + + context.restore() + context.new_path() #forget about us + + +class BitmapSprite(Sprite): + """Caches given image data in a surface similar to targets, which ensures + that drawing it will be quick and low on CPU. + Image data can be either :class:`cairo.ImageSurface` or :class:`gtk.gdk.Pixbuf` + """ + def __init__(self, image_data = None, cache_mode = None, **kwargs): + Sprite.__init__(self, **kwargs) + + self.width, self.height = None, None + self.cache_mode = cache_mode or cairo.CONTENT_COLOR_ALPHA + #: image data + self.image_data = image_data + + self._surface = None + + self.cache_attrs = self.cache_attrs ^ set(('_surface',)) + + def __setattr__(self, name, val): + Sprite.__setattr__(self, name, val) + if name == 'image_data': + self.__dict__['_surface'] = None + if self.image_data: + self.__dict__['width'] = self.image_data.get_width() + self.__dict__['height'] = self.image_data.get_height() + + def _draw(self, context, opacity = 1, parent_matrix = None): + if self.image_data is None or self.width is None or self.height is None: + return + + if not self._surface: + # caching image on surface similar to the target + surface = context.get_target().create_similar(self.cache_mode, + self.width, + self.height) + + + local_context = gtk.gdk.CairoContext(cairo.Context(surface)) + if isinstance(self.image_data, gtk.gdk.Pixbuf): + local_context.set_source_pixbuf(self.image_data, 0, 0) + else: + local_context.set_source_surface(self.image_data) + local_context.paint() + + # add instructions with the resulting surface + self.graphics.clear() + self.graphics.rectangle(0, 0, self.width, self.height) + self.graphics.clip() + self.graphics.set_source_surface(surface) + self.graphics.paint() + self._surface = surface + + + Sprite._draw(self, context, opacity, parent_matrix) + + +class Image(BitmapSprite): + """Displays image by path. Currently supports only PNG images.""" + def __init__(self, path, **kwargs): + BitmapSprite.__init__(self, **kwargs) + + #: path to the image + self.path = path + + def __setattr__(self, name, val): + BitmapSprite.__setattr__(self, name, val) + if name == 'path': # load when the value is set to avoid penalty on render + self.image_data = cairo.ImageSurface.create_from_png(self.path) + + + +class Icon(BitmapSprite): + """Displays icon by name and size in the theme""" + def __init__(self, name, size=24, **kwargs): + BitmapSprite.__init__(self, **kwargs) + self.theme = gtk.icon_theme_get_default() + + #: icon name from theme + self.name = name + + #: icon size in pixels + self.size = size + + def __setattr__(self, name, val): + BitmapSprite.__setattr__(self, name, val) + if name in ('name', 'size'): # no other reason to discard cache than just on path change + if self.__dict__.get('name') and self.__dict__.get('size'): + self.image_data = self.theme.load_icon(self.name, self.size, 0) + else: + self.image_data = None + + +class Label(Sprite): + __gsignals__ = { + "on-change": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), + } + def __init__(self, text = "", size = 10, color = None, + alignment = pango.ALIGN_LEFT, + max_width = None, wrap = None, ellipsize = None, + **kwargs): + Sprite.__init__(self, **kwargs) + self.width, self.height = None, None + + + self._test_context = gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A8, 0, 0))) + self._test_layout = self._test_context.create_layout() + + + #: pango.FontDescription, default is the system's font + self.font_desc = pango.FontDescription(gtk.Style().font_desc.to_string()) + self.font_desc.set_size(size * pango.SCALE) + + #: color of label either as hex string or an (r,g,b) tuple + self.color = color + + self._bounds_width = None + + #: wrapping method. Can be set to pango. [WRAP_WORD, WRAP_CHAR, + #: WRAP_WORD_CHAR] + self.wrap = wrap + + #: Ellipsize mode. Can be set to pango. [ELLIPSIZE_NONE, + #: ELLIPSIZE_START, ELLIPSIZE_MIDDLE, ELLIPSIZE_END] + self.ellipsize = ellipsize + + #: alignment. one of pango.[ALIGN_LEFT, ALIGN_RIGHT, ALIGN_CENTER] + self.alignment = alignment + + #: font size + self.size = size + + #: maximum width of the label in pixels. if specified, the label + #: will be wrapped or ellipsized depending on the wrap and ellpisize settings + self.max_width = max_width + + self.__surface = None + + #: label text + self.text = text + + self._measures = {} + + self.connect("on-render", self.on_render) + + self.cache_attrs = self.cache_attrs ^ set(("_letter_sizes", "__surface", "_ascent", "_bounds_width", "_measures")) + + + def __setattr__(self, name, val): + if self.__dict__.get(name, "hamster_graphics_no_value_really") != val: + if name == "width" and val and self.__dict__.get('_bounds_width') and val * pango.SCALE == self.__dict__['_bounds_width']: + return + + Sprite.__setattr__(self, name, val) + + + if name == "width": + # setting width means consumer wants to contrain the label + if val is None or val == -1: + self.__dict__['_bounds_width'] = None + else: + self.__dict__['_bounds_width'] = val * pango.SCALE + + if name in ("width", "text", "size", "font_desc", "wrap", "ellipsize", "max_width"): + self._measures = {} + # avoid chicken and egg + if hasattr(self, "text") and hasattr(self, "size"): + self.__dict__['width'], self.__dict__['height'] = self.measure(self.text) + + if name == 'text': + self.emit('on-change') + + + def measure(self, text): + """measures given text with label's font and size. + returns width, height and ascent. Ascent's null in case if the label + does not have font face specified (and is thusly using pango)""" + + if text in self._measures: + return self._measures[text] + + width, height = None, None + + context = self._test_context + + layout = self._test_layout + layout.set_font_description(self.font_desc) + layout.set_markup(text) + + max_width = 0 + if self.max_width: + max_width = self.max_width * pango.SCALE + + layout.set_width(int(self._bounds_width or max_width or -1)) + layout.set_ellipsize(pango.ELLIPSIZE_NONE) + + if self.wrap is not None: + layout.set_wrap(self.wrap) + else: + layout.set_ellipsize(self.ellipsize or pango.ELLIPSIZE_END) + + width, height = layout.get_pixel_size() + + self._measures[text] = width, height + return self._measures[text] + + + def on_render(self, sprite): + if not self.text: + self.graphics.clear() + return + + self.graphics.set_color(self.color) + + rect_width = self.width + + max_width = 0 + if self.max_width: + max_width = self.max_width * pango.SCALE + + # when max width is specified and we are told to align in center + # do that (the pango instruction takes care of aligning within + # the lines of the text) + if self.alignment == pango.ALIGN_CENTER: + self.graphics.move_to(-(self.max_width - self.width)/2, 0) + + bounds_width = max_width or self._bounds_width or -1 + + self.graphics.show_layout(self.text, self.font_desc, + self.alignment, + bounds_width, + self.wrap, + self.ellipsize) + + if self._bounds_width: + rect_width = self._bounds_width / pango.SCALE + + self.graphics.rectangle(0, 0, rect_width, self.height) + self.graphics.clip() + + + +class Rectangle(Sprite): + def __init__(self, w, h, corner_radius = 0, fill = None, stroke = None, line_width = 1, **kwargs): + Sprite.__init__(self, **kwargs) + + #: width + self.width = w + + #: height + self.height = h + + #: fill color + self.fill = fill + + #: stroke color + self.stroke = stroke + + #: stroke line width + self.line_width = line_width + + #: corner radius. Set bigger than 0 for rounded corners + self.corner_radius = corner_radius + self.connect("on-render", self.on_render) + + def on_render(self, sprite): + self.graphics.set_line_style(width = self.line_width) + self.graphics.rectangle(0, 0, self.width, self.height, self.corner_radius) + self.graphics.fill_stroke(self.fill, self.stroke, self.line_width) + + +class Polygon(Sprite): + def __init__(self, points, fill = None, stroke = None, line_width = 1, **kwargs): + Sprite.__init__(self, **kwargs) + + #: list of (x,y) tuples that the line should go through. Polygon + #: will automatically close path. + self.points = points + + #: fill color + self.fill = fill + + #: stroke color + self.stroke = stroke + + #: stroke line width + self.line_width = line_width + + self.connect("on-render", self.on_render) + + def on_render(self, sprite): + if not self.points: return + + self.graphics.move_to(*self.points[0]) + self.graphics.line_to(self.points) + self.graphics.close_path() + + self.graphics.fill_stroke(self.fill, self.stroke, self.line_width) + + +class Circle(Sprite): + def __init__(self, width, height, fill = None, stroke = None, line_width = 1, **kwargs): + Sprite.__init__(self, **kwargs) + + #: circle width + self.width = width + + #: circle height + self.height = height + + #: fill color + self.fill = fill + + #: stroke color + self.stroke = stroke + + #: stroke line width + self.line_width = line_width + + self.connect("on-render", self.on_render) + + def on_render(self, sprite): + if self.width == self.height: + radius = self.width / 2.0 + self.graphics.circle(radius, radius, radius) + else: + self.graphics.ellipse(0, 0, self.width, self.height) + + self.graphics.fill_stroke(self.fill, self.stroke, self.line_width) + + +class Scene(gtk.DrawingArea): + """ Drawing area for displaying sprites. + Add sprites to the Scene by calling :func:`add_child`. + Scene is descendant of `gtk.DrawingArea <http://www.pygtk.org/docs/pygtk/class-gtkdrawingarea.html>`_ + and thus inherits all it's methods and everything. + """ + + __gsignals__ = { + "expose-event": "override", + "configure_event": "override", + "on-enter-frame": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )), + "on-finish-frame": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )), + + "on-click": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)), + "on-drag": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)), + "on-drag-start": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)), + "on-drag-finish": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)), + + "on-mouse-move": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-mouse-down": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-mouse-up": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-mouse-over": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + "on-mouse-out": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + + "on-scroll": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), + } + + def __init__(self, interactive = True, framerate = 60, + background_color = None, scale = False, keep_aspect = True): + gtk.DrawingArea.__init__(self) + if interactive: + self.set_events(gtk.gdk.POINTER_MOTION_MASK + | gtk.gdk.LEAVE_NOTIFY_MASK | gtk.gdk.ENTER_NOTIFY_MASK + | gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON_RELEASE_MASK + | gtk.gdk.SCROLL_MASK + | gtk.gdk.KEY_PRESS_MASK) + self.connect("motion_notify_event", self.__on_mouse_move) + self.connect("enter_notify_event", self.__on_mouse_enter) + self.connect("leave_notify_event", self.__on_mouse_leave) + self.connect("button_press_event", self.__on_button_press) + self.connect("button_release_event", self.__on_button_release) + self.connect("scroll-event", self.__on_scroll) + + #: list of sprites in scene. use :func:`add_child` to add sprites + self.sprites = [] + + #: framerate of animation. This will limit how often call for + #: redraw will be performed (that is - not more often than the framerate). It will + #: also influence the smoothness of tweeners. + self.framerate = framerate + + #: Scene width. Will be `None` until first expose (that is until first + #: on-enter-frame signal below). + self.width = None + + #: Scene height. Will be `None` until first expose (that is until first + #: on-enter-frame signal below). + self.height = None + + #: instance of :class:`pytweener.Tweener` that is used by + #: :func:`animate` function, but can be also accessed directly for advanced control. + self.tweener = False + if pytweener: + self.tweener = pytweener.Tweener(0.4, pytweener.Easing.Cubic.ease_in_out) + + #: instance of :class:`Colors` class for color parsing + self.colors = Colors + + #: read only info about current framerate (frames per second) + self.fps = 0 # inner frames per second counter + + #: Last known x position of the mouse (set on expose event) + self.mouse_x = None + + #: Last known y position of the mouse (set on expose event) + self.mouse_y = None + + #: Background color of the scene. Use either a string with hex color or an RGB triplet. + self.background_color = background_color + + #: Mouse cursor appearance. + #: Replace with your own cursor or set to False to have no cursor. + #: None will revert back the default behavior + self.mouse_cursor = None + + blank_pixmap = gtk.gdk.Pixmap(None, 1, 1, 1) + self._blank_cursor = gtk.gdk.Cursor(blank_pixmap, blank_pixmap, gtk.gdk.Color(), gtk.gdk.Color(), 0, 0) + + + #: Miminum distance in pixels for a drag to occur + self.drag_distance = 1 + + self._last_frame_time = None + self._mouse_sprite = None + self._drag_sprite = None + self._mouse_down_sprite = None + self.__drag_started = False + self.__drag_start_x, self.__drag_start_y = None, None + + self._mouse_in = False + self.__last_cursor = None + + self.__drawing_queued = False + self._redraw_in_progress = False + + #: When specified, upon window resize the content will be scaled + #: relative to original window size. Defaults to False. + self.scale = scale + + #: Should the stage maintain aspect ratio upon scale if + #: :attr:`Scene.scale` is enabled. Defaults to true. + self.keep_aspect = keep_aspect + + self._original_width, self._original_height = None, None + + + + def add_child(self, *sprites): + """Add one or several :class:`Sprite` objects to the scene""" + for sprite in sprites: + if sprite == self: + raise Exception("trying to add sprite to itself") + if sprite.parent: + sprite.x, sprite.y = sprite.to_scene_coords(0, 0) + sprite.parent.remove_child(sprite) + self.sprites.append(sprite) + sprite.parent = self + self._sort() + + def _sort(self): + """sort sprites by z_order""" + self.sprites = sorted(self.sprites, key=lambda sprite:sprite.z_order) + + + def remove_child(self, *sprites): + """Remove one or several :class:`Sprite` sprites from scene """ + for sprite in sprites: + self.sprites.remove(sprite) + sprite._scene = None + sprite.parent = None + + # these two mimic sprite functions so parent check can be avoided + def from_scene_coords(self, x, y): return x, y + def to_scene_coords(self, x, y): return x, y + def get_matrix(self): return cairo.Matrix() + + def clear(self): + """Remove all sprites from scene""" + self.remove_child(*self.sprites) + + def animate(self, sprite, duration = None, easing = None, on_complete = None, on_update = None, **kwargs): + """Interpolate attributes of the given object using the internal tweener + and redrawing scene after every tweener update. + Specify the sprite and sprite's attributes that need changing. + `duration` defaults to 0.4 seconds and `easing` to cubic in-out + (for others see pytweener.Easing class). + + Redraw is requested right after creating the animation. + Example:: + + # tween some_sprite to coordinates (50,100) using default duration and easing + scene.animate(some_sprite, x = 50, y = 100) + """ + if not self.tweener: # here we complain + raise Exception("pytweener was not found. Include it to enable animations") + + tween = self.tweener.add_tween(sprite, + duration=duration, + easing=easing, + on_complete=on_complete, + on_update=on_update, + **kwargs) + self.redraw() + return tween + + + def redraw(self): + """Queue redraw. The redraw will be performed not more often than + the `framerate` allows""" + if self.__drawing_queued == False: #if we are moving, then there is a timeout somewhere already + self.__drawing_queued = True + self._last_frame_time = dt.datetime.now() + gobject.timeout_add(1000 / self.framerate, self.__redraw_loop) + + def __redraw_loop(self): + """loop until there is nothing more to tween""" + self.queue_draw() # this will trigger do_expose_event when the current events have been flushed + + self.__drawing_queued = self.tweener and self.tweener.has_tweens() + return self.__drawing_queued + + + def do_expose_event(self, event): + context = self.window.cairo_create() + + # clip to the visible part + context.rectangle(event.area.x, event.area.y, + event.area.width, event.area.height) + if self.background_color: + color = self.colors.parse(self.background_color) + context.set_source_rgb(*color) + context.fill_preserve() + context.clip() + + if self.scale: + aspect_x = self.width / self._original_width + aspect_y = self.height / self._original_height + if self.keep_aspect: + aspect_x = aspect_y = min(aspect_x, aspect_y) + context.scale(aspect_x, aspect_y) + + self.mouse_x, self.mouse_y, mods = self.get_window().get_pointer() + + self._redraw_in_progress = True + + # update tweens + now = dt.datetime.now() + delta = (now - (self._last_frame_time or dt.datetime.now())).microseconds / 1000000.0 + self._last_frame_time = now + if self.tweener: + self.tweener.update(delta) + + self.fps = 1 / delta + + + # start drawing + self.emit("on-enter-frame", context) + for sprite in self.sprites: + sprite._draw(context) + + self.__check_mouse(self.mouse_x, self.mouse_y) + self.emit("on-finish-frame", context) + self._redraw_in_progress = False + + + def do_configure_event(self, event): + if self._original_width is None: + self._original_width = float(event.width) + self._original_height = float(event.height) + + self.width, self.height = event.width, event.height + + + def all_visible_sprites(self): + """Returns flat list of the sprite tree for simplified iteration""" + def all_recursive(sprites): + for sprite in sprites: + if sprite.visible: + yield sprite + if sprite.sprites: + for child in all_recursive(sprite.sprites): + yield child + + return all_recursive(self.sprites) + + + def get_sprite_at_position(self, x, y): + """Returns the topmost visible interactive sprite for given coordinates""" + over = None + + for sprite in self.all_visible_sprites(): + if (sprite.interactive or sprite.draggable) and sprite.check_hit(x, y): + over = sprite + + return over + + + def __check_mouse(self, x, y): + if x is None or self._mouse_in == False: + return + + cursor = gtk.gdk.ARROW # default + + if self.mouse_cursor is not None: + cursor = self.mouse_cursor + + if self._drag_sprite: + cursor = self._drag_sprite.mouse_cursor or self.mouse_cursor or gtk.gdk.FLEUR + else: + #check if we have a mouse over + over = self.get_sprite_at_position(x, y) + if self._mouse_sprite and self._mouse_sprite != over: + self._mouse_sprite.emit("on-mouse-out") + self.emit("on-mouse-out", self._mouse_sprite) + + if over: + if over.mouse_cursor is not None: + cursor = over.mouse_cursor + + elif self.mouse_cursor is None: + # resort to defaults + if over.draggable: + cursor = gtk.gdk.FLEUR + else: + cursor = gtk.gdk.HAND2 + + if over != self._mouse_sprite: + over.emit("on-mouse-over") + self.emit("on-mouse-over", over) + + self._mouse_sprite = over + + if cursor == False: + cursor = self._blank_cursor + + if not self.__last_cursor or cursor != self.__last_cursor: + if isinstance(cursor, gtk.gdk.Cursor): + self.window.set_cursor(cursor) + else: + self.window.set_cursor(gtk.gdk.Cursor(cursor)) + + self.__last_cursor = cursor + + + """ mouse events """ + def __on_mouse_move(self, area, event): + state = event.state + + + if self._mouse_down_sprite and self._mouse_down_sprite.draggable \ + and gtk.gdk.BUTTON1_MASK & event.state: + # dragging around + if not self.__drag_started: + drag_started = (self.__drag_start_x is not None and \ + (self.__drag_start_x - event.x) ** 2 + \ + (self.__drag_start_y - event.y) ** 2 > self.drag_distance ** 2) + + if drag_started: + self._drag_sprite = self._mouse_down_sprite + + self._drag_sprite.drag_x, self._drag_sprite.drag_y = self._drag_sprite.x, self._drag_sprite.y + + self._drag_sprite.emit("on-drag-start", event) + self.emit("on-drag-start", self._drag_sprite, event) + + self.__drag_started = True + + if self.__drag_started: + diff_x, diff_y = event.x - self.__drag_start_x, event.y - self.__drag_start_y + if isinstance(self._drag_sprite.parent, Sprite): + matrix = self._drag_sprite.parent.get_matrix() + matrix.invert() + diff_x, diff_y = matrix.transform_distance(diff_x, diff_y) + + self._drag_sprite.x, self._drag_sprite.y = self._drag_sprite.drag_x + diff_x, self._drag_sprite.drag_y + diff_y + + self._drag_sprite.emit("on-drag", event) + self.emit("on-drag", self._drag_sprite, event) + + else: + # avoid double mouse checks - the redraw will also check for mouse! + if not self.__drawing_queued: + self.__check_mouse(event.x, event.y) + + self.emit("on-mouse-move", event) + + def __on_mouse_enter(self, area, event): + self._mouse_in = True + + def __on_mouse_leave(self, area, event): + self._mouse_in = False + if self._mouse_sprite: + self.emit("on-mouse-out", self._mouse_sprite) + self._mouse_sprite = None + + + def __on_button_press(self, area, event): + target = self.get_sprite_at_position(event.x, event.y) + self.__drag_start_x, self.__drag_start_y = event.x, event.y + + self._mouse_down_sprite = target + + if target: + target.emit("on-mouse-down", event) + self.emit("on-mouse-down", event) + + def __on_button_release(self, area, event): + target = self.get_sprite_at_position(event.x, event.y) + + if target: + target.emit("on-mouse-up", event) + self.emit("on-mouse-up", event) + + # trying to not emit click and drag-finish at the same time + click = not self.__drag_started or (event.x - self.__drag_start_x) ** 2 + \ + (event.y - self.__drag_start_y) ** 2 < self.drag_distance + if (click and self.__drag_started == False) or not self._drag_sprite: + if target: + target.emit("on-click", event) + + self.emit("on-click", event, target) + + if self._drag_sprite: + self._drag_sprite.emit("on-drag-finish", event) + self.emit("on-drag-finish", self._drag_sprite, event) + + self._drag_sprite.drag_x, self._drag_sprite.drag_y = None, None + self._drag_sprite = None + self._mouse_down_sprite = None + + self.__drag_started = False + self.__drag_start_x, self__drag_start_y = None, None + + def __on_scroll(self, area, event): + self.emit("on-scroll", event) diff --git a/lib/proximity.py b/lib/proximity.py new file mode 100644 index 0000000..fc4d6a1 --- /dev/null +++ b/lib/proximity.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# - coding: utf-8 - +# Copyright (C) 2010 Toms Bauģis <toms.baugis at gmail.com> + +""" + Proximity calculations +""" + +from bisect import bisect + +class ProximityStore(object): + def __init__(self): + self.positions = {} + self.reverse_positions = {} + + def update_position(position): + """Update position of the element""" + pass + + def find_neighbours(location, radius): + pass + + +# A AbstractProximityDatabase-style wrapper for the LQ bin lattice system +class LQProximityStore(ProximityStore): + __slots__ = ['point1', 'point2', 'stride', 'grid_x', 'grid_y'] + def __init__(self, point1, point2, stride): + ProximityStore.__init__(self) + self.point1, self.point2, self.stride = point1, point2, stride + + # create the bin grid where we will be throwing in our friends + self.grid_x = range(point1.x, point2.x, stride) + self.grid_y = range(point1.y, point2.y, stride) + + self.velocity_weight = 10 + + + def update_position(self, boid): + bin = (bisect(self.grid_x, boid.location.x), bisect(self.grid_y, boid.location.y)) + old_bin = self.reverse_positions.setdefault(boid, []) + + #if bin has changed, move + if old_bin != bin: + if old_bin: + self.positions[old_bin].remove(boid) + + self.positions.setdefault(bin, []) + self.positions[bin].append(boid) + self.reverse_positions[boid] = bin + + + def find_bins(self, boid, radius): + # TODO, would be neat to operate with vectors here + # create a bounding box and return all bins within it + velocity_weight = self.velocity_weight + min_x = bisect(self.grid_x, min(boid.location.x - radius, + boid.location.x + boid.velocity.x * velocity_weight - radius)) + min_y = bisect(self.grid_y, min(boid.location.y - radius, + boid.location.y + boid.velocity.y * velocity_weight - radius)) + max_x = bisect(self.grid_x, max(boid.location.x + radius, + boid.location.x + boid.velocity.x * velocity_weight + radius)) + max_y = bisect(self.grid_y, max(boid.location.y + radius, + boid.location.y + boid.velocity.y * velocity_weight + radius)) + + bins = [] + for x in range(min_x, max_x + 1): + for y in range(min_y, max_y + 1): + bins.append(self.positions.setdefault((x,y), [])) + return bins + + + def find_neighbours(self, boid, radius): + bins = self.find_bins(boid, radius) + + neighbours = [] + + for bin in bins: + for boid2 in bin: + if boid is boid2: + continue + + dx = boid.location.x - boid2.location.x + dy = boid.location.y - boid2.location.y + d = dx * dx + dy * dy + if d < radius * radius: + neighbours.append((boid2, d)) + + return neighbours diff --git a/lib/pytweener.py b/lib/pytweener.py new file mode 100644 index 0000000..f5cacd7 --- /dev/null +++ b/lib/pytweener.py @@ -0,0 +1,343 @@ +# pyTweener +# +# Tweening functions for python +# +# Heavily based on caurina Tweener: http://code.google.com/p/tweener/ +# +# Released under M.I.T License - see above url +# Python version by Ben Harling 2009 +# All kinds of slashing and dashing by Toms Baugis 2010 +import math +import collections +import datetime as dt +import time +import re + +class Tweener(object): + def __init__(self, default_duration = None, tween = None): + """Tweener + This class manages all active tweens, and provides a factory for + creating and spawning tween motions.""" + self.current_tweens = collections.defaultdict(set) + self.default_easing = tween or Easing.Cubic.ease_in_out + self.default_duration = default_duration or 1.0 + + def has_tweens(self): + return len(self.current_tweens) > 0 + + + def add_tween(self, obj, duration = None, easing = None, on_complete = None, on_update = None, **kwargs): + """ + Add tween for the object to go from current values to set ones. + Example: add_tween(sprite, x = 500, y = 200, duration = 0.4) + This will move the sprite to coordinates (500, 200) in 0.4 seconds. + For parameter "easing" you can use one of the pytweener.Easing + functions, or specify your own. + The tweener can handle numbers, dates and color strings in hex ("#ffffff"). + This function performs overwrite style conflict solving - in case + if a previous tween operates on same attributes, the attributes in + question are removed from that tween. + """ + if duration is None: + duration = self.default_duration + + easing = easing or self.default_easing + + tw = Tween(obj, duration, easing, on_complete, on_update, **kwargs ) + + if obj in self.current_tweens: + for current_tween in tuple(self.current_tweens[obj]): + prev_keys = set((key for (key, tweenable) in current_tween.tweenables)) + dif = prev_keys & set(kwargs.keys()) + + for key, tweenable in tuple(current_tween.tweenables): + if key in dif: + current_tween.tweenables.remove((key, tweenable)) + + if not current_tween.tweenables: + current_tween.finish() + self.current_tweens[obj].remove(current_tween) + + + self.current_tweens[obj].add(tw) + return tw + + + def get_tweens(self, obj): + """Get a list of all tweens acting on the specified object + Useful for manipulating tweens on the fly""" + return self.current_tweens.get(obj, None) + + def kill_tweens(self, obj = None): + """Stop tweening an object, without completing the motion or firing the + on_complete""" + if obj: + try: + del self.current_tweens[obj] + except: + pass + else: + self.current_tweens = collections.defaultdict(set) + + def remove_tween(self, tween): + """"remove given tween without completing the motion or firing the on_complete""" + if tween.target in self.current_tweens and tween in self.current_tweens[tween.target]: + self.current_tweens[tween.target].remove(tween) + if not self.current_tweens[tween.target]: + del self.current_tweens[tween.target] + + def finish(self): + """jump the the last frame of all tweens""" + for obj in self.current_tweens: + for tween in self.current_tweens[obj]: + tween.finish() + self.current_tweens = {} + + def update(self, delta_seconds): + """update tweeners. delta_seconds is time in seconds since last frame""" + + for obj in tuple(self.current_tweens): + for tween in tuple(self.current_tweens[obj]): + done = tween._update(delta_seconds) + if done: + self.current_tweens[obj].remove(tween) + if tween.on_complete: tween.on_complete(tween.target) + + if not self.current_tweens[obj]: + del self.current_tweens[obj] + + return self.current_tweens + + +class Tween(object): + __slots__ = ('tweenables', 'target', 'delta', 'duration', 'ease', 'delta', + 'on_complete', 'on_update', 'complete') + + def __init__(self, obj, duration, easing, on_complete, on_update, **kwargs): + """Tween object use Tweener.add_tween( ... ) to create""" + self.duration = duration + self.target = obj + self.ease = easing + + # list of (property, start_value, delta) + self.tweenables = set() + for key, value in kwargs.items(): + self.tweenables.add((key, Tweenable(self.target.__dict__[key], value))) + + self.delta = 0 + self.on_complete = on_complete + self.on_update = on_update + self.complete = False + + def finish(self): + self._update(self.duration) + + def _update(self, ptime): + """Update tween with the time since the last frame""" + self.delta = self.delta + ptime + if self.delta > self.duration: + self.delta = self.duration + + if self.delta == self.duration: + for key, tweenable in self.tweenables: + self.target.__setattr__(key, tweenable.target_value) + else: + fraction = self.ease(self.delta / self.duration) + + for key, tweenable in self.tweenables: + self.target.__setattr__(key, tweenable.update(fraction)) + + if self.delta == self.duration or len(self.tweenables) == 0: + self.complete = True + + if self.on_update: + self.on_update(self.target) + + return self.complete + + + + +class Tweenable(object): + """a single attribute that has to be tweened from start to target""" + __slots__ = ('start_value', 'change', 'decode_func', 'target_value', 'update') + + hex_color_normal = re.compile("#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})") + hex_color_short = re.compile("#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])") + + + def __init__(self, start_value, target_value): + self.decode_func = lambda x: x + self.target_value = target_value + + def float_update(fraction): + return self.start_value + self.change * fraction + + def date_update(fraction): + return dt.date.fromtimestamp(self.start_value + self.change * fraction) + + def datetime_update(fraction): + return dt.datetime.fromtimestamp(self.start_value + self.change * fraction) + + def color_update(fraction): + val = [max(min(self.start_value[i] + self.change[i] * fraction, 255), 0) for i in range(3)] + return "#%02x%02x%02x" % (val[0], val[1], val[2]) + + + if isinstance(start_value, int) or isinstance(start_value, float): + self.start_value = start_value + self.change = target_value - start_value + self.update = float_update + else: + if isinstance(start_value, dt.datetime) or isinstance(start_value, dt.date): + if isinstance(start_value, dt.datetime): + self.update = datetime_update + else: + self.update = date_update + + self.decode_func = lambda x: time.mktime(x.timetuple()) + self.start_value = self.decode_func(start_value) + self.change = self.decode_func(target_value) - self.start_value + + elif isinstance(start_value, basestring) \ + and (self.hex_color_normal.match(start_value) or self.hex_color_short.match(start_value)): + self.update = color_update + if self.hex_color_normal.match(start_value): + self.decode_func = lambda val: [int(match, 16) + for match in self.hex_color_normal.match(val).groups()] + + elif self.hex_color_short.match(start_value): + self.decode_func = lambda val: [int(match + match, 16) + for match in self.hex_color_short.match(val).groups()] + + if self.hex_color_normal.match(target_value): + target_value = [int(match, 16) + for match in self.hex_color_normal.match(target_value).groups()] + else: + target_value = [int(match + match, 16) + for match in self.hex_color_short.match(target_value).groups()] + + self.start_value = self.decode_func(start_value) + self.change = [target - start for start, target in zip(self.start_value, target_value)] + + + +"""Robert Penner's classes stripped from the repetetive c,b,d mish-mash +(discovery of Patryk Zawadzki). This way we do the math once and apply to +all the tweenables instead of repeating it for each attribute +""" + +def inverse(method): + def real_inverse(t, *args, **kwargs): + t = 1 - t + return 1 - method(t, *args, **kwargs) + return real_inverse + +def symmetric(ease_in, ease_out): + def real_symmetric(t, *args, **kwargs): + if t < 0.5: + return ease_in(t * 2, *args, **kwargs) / 2 + + return ease_out((t - 0.5) * 2, *args, **kwargs) / 2 + 0.5 + return real_symmetric + +class Symmetric(object): + def __init__(self, ease_in = None, ease_out = None): + self.ease_in = ease_in or inverse(ease_out) + self.ease_out = ease_out or inverse(ease_in) + self.ease_in_out = symmetric(self.ease_in, self.ease_out) + + +class Easing(object): + """Class containing easing classes to use together with the tweener. + All of the classes have :func:`ease_in`, :func:`ease_out` and + :func:`ease_in_out` functions.""" + + Linear = Symmetric(lambda t: t, lambda t: t) + Quad = Symmetric(lambda t: t*t) + Cubic = Symmetric(lambda t: t*t*t) + Quart = Symmetric(lambda t: t*t*t*t) + Quint = Symmetric(lambda t: t*t*t*t*t) + Strong = Quint #oh i wonder why but the ported code is the same as in Quint + + Circ = Symmetric(lambda t: 1 - math.sqrt(1 - t * t)) + Sine = Symmetric(lambda t: 1 - math.cos(t * (math.pi / 2))) + + + def _back_in(t, s=1.70158): + return t * t * ((s + 1) * t - s) + Back = Symmetric(_back_in) + + + def _bounce_out(t): + if t < 1 / 2.75: + return 7.5625 * t * t + elif t < 2 / 2.75: + t = t - 1.5 / 2.75 + return 7.5625 * t * t + 0.75 + elif t < 2.5 / 2.75: + t = t - 2.25 / 2.75 + return 7.5625 * t * t + .9375 + else: + t = t - 2.625 / 2.75 + return 7.5625 * t * t + 0.984375 + Bounce = Symmetric(ease_out = _bounce_out) + + + def _elastic_in(t, springiness = 0, wave_length = 0): + if t in(0, 1): + return t + + wave_length = wave_length or (1 - t) * 0.3 + + if springiness <= 1: + springiness = t + s = wave_length / 4 + else: + s = wave_length / (2 * math.pi) * math.asin(t / springiness) + + t = t - 1 + return -(springiness * math.pow(2, 10 * t) * math.sin((t * t - s) * (2 * math.pi) / wave_length)) + Elastic = Symmetric(_elastic_in) + + + def _expo_in(t): + if t in (0, 1): return t + return math.pow(2, 10 * t) * 0.001 + Expo = Symmetric(_expo_in) + + + +class _Dummy(object): + def __init__(self, a, b, c): + self.a = a + self.b = b + self.c = c + +if __name__ == "__main__": + import datetime as dt + + tweener = Tweener() + objects = [] + + for i in range(10000): + objects.append(_Dummy(dt.datetime.now(), i-100, i-100)) + + + total = dt.datetime.now() + + t = dt.datetime.now() + print "Adding 10000 objects..." + for i, o in enumerate(objects): + tweener.add_tween(o, a = dt.datetime.now() - dt.timedelta(days=3), + b = i, + c = i, + duration = 1.0, + easing=Easing.Circ.ease_in_out) + print dt.datetime.now() - t + + t = dt.datetime.now() + print "Updating 10 times......" + for i in range(11): #update 1000 times + tweener.update(0.1) + print dt.datetime.now() - t |