# Copyright 2008 by Peter Moxhay and Wade Brainerd. # This file is part of Math. # # Math is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Math 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Math. If not, see . from vector import Vector import gtk, gobject, math GRID_VISIBLE = False # Defined coordinate system of object areas. The canvas will scale objects to make # this coordinate system match the size of the window, while preserving aspect ratio. AREA_WIDTH = 1200 AREA_HEIGHT = 800 # Class containing various standard colors. Each color is a 3 element tuple. class Color: BLUE = (0.25, 0.25, 0.75) GREEN = (0.25, 0.75, 0.25) RED = (0.75, 0.25, 0.25) class Object: """ Objects are shapes with which the user can interact. They are contained within ObjectArea widgets. Object subclasses must implement the following methods: draw(self, cr): Draw's the object using a Cairo graphics context. contains_point(self, pos): Returns True if pos is within the object. """ def __init__(self): self.pos = Vector(0, 0) self.angle = 0 self.scale = 1.0 self.z = 0 self.selectable = False self.selected = False self.dragged = False self.container = None self.animating = False self.GRID_SIZE = 50 self.DRAGGING_RECT_WIDTH = 24*self.GRID_SIZE self.DRAGGING_RECT_HEIGHT = 16*self.GRID_SIZE self.RADIAL_GRID_SIZE = math.pi/4 def draw(self, cr): pass def get_bounds(self): return Vector(0, 0), Vector(AREA_WIDTH, AREA_HEIGHT) def contains_point(self, pos): mn, mx = self.get_bounds() return pos.x >= mn.x and pos.x <= mx.x and \ pos.y >= mn.y and pos.y <= mx.y def contains_rectangle(self, other_bounds): b = False this_mn, this_mx = self.get_bounds() other_mn, other_mx = other_bounds if other_mn.x >= this_mn.x and other_mx.x <= this_mx.x and other_mn.y >= this_mn.y and other_mx.y <= this_mx.y: b = True return b def abort_drag(other): pass def queue_draw(self): """Called whenever the object needs to be redrawn on screen.""" if self.container: self.container.queue_draw_bounds(self.get_bounds()) def move(self, pos): self.queue_draw() self.pos = pos self.queue_draw() def rotate(self, angle): self.queue_draw() self.angle = angle self.queue_draw() def scale(self, scale): self.queue_draw() self.scale = scale self.queue_draw() def start_animating(self): self.container.start_animating_object(self) self.animating = True def stop_animating(self): self.container.stop_animating_object(self) self.animating = False def animate(self): pass def on_key(self, event): pass def on_mouse(self, event): pass class ObjectArea(gtk.Layout): """Widget containing interactive Objects.""" def __init__(self, activity): gtk.Layout.__init__(self) self.activity = activity self.objects = [] # Sub-list of objects that are currently animating. self.animating_objects = [] # Object currently selected. self.selected_object = None # Object currently being dragged. self.drag_object = None self.drop_object = None # Current mouse cursor. self.cursor = None self.queued_cursor = None # Bounding rectangle and screen scale. self.bounds = gtk.gdk.Rectangle() self.scale_ratio = 1.0 # Set up mouse events. self.add_events(gtk.gdk.POINTER_MOTION_MASK|gtk.gdk.POINTER_MOTION_HINT_MASK) self.connect('motion-notify-event', self.on_mouse) self.add_events(gtk.gdk.BUTTON_PRESS_MASK|gtk.gdk.BUTTON_RELEASE_MASK) self.connect('button-press-event', self.on_mouse) self.connect('button-release-event', self.on_mouse) # Set up keyboard events. self.add_events(gtk.gdk.KEY_PRESS_MASK) self.activity.connect('key-press-event', self.on_key) # Set up drawing events. self.modify_bg(gtk.STATE_NORMAL, self.get_colormap().alloc_color('#ffffff')) self.connect('expose-event', self.expose_cb) self.timer_id = None def check_problem_solved(self): pass def add_object(self, obj): obj.container = self self.objects.append(obj) obj.queue_draw() def remove_object(self, obj): # Draw the area underneath the object. obj.queue_draw() obj.container = None # Ignore failure to remove when an object has already been removed. try: self.objects.remove(obj) except: pass def queue_draw_bounds(self, bounds): x1 = int(bounds[0].x * self.scale_ratio) y1 = int(bounds[0].y * self.scale_ratio) x2 = int(bounds[1].x * self.scale_ratio) y2 = int(bounds[1].y * self.scale_ratio) self.queue_draw_area(x1, y1, (x2-x1)+1, (y2-y1)+1) def clear_selection(self): if self.selected_object: self.selected_object.selected = False self.selected_object.queue_draw() def select_object(self, object): old_selected_object = self.selected_object if self.selected_object: self.selected_object.selected = False self.selected_object = object object.selected = True # Move to front of list so it draws on top. # TODO: Screws up tab key. Maybe we need separate draw & tab lists? #self.objects.remove(object) #self.objects.insert(0, object) # Make the selected object come to the front. #self.remove_object(object) #self.add_object(object) self.adjust_tab_order() if old_selected_object: old_selected_object.queue_draw() object.queue_draw() def start_animating_object(self, object): if self.animating_objects.count(object) == 0: self.animating_objects.append(object) if self.timer_id is None: self.timer_id = gobject.timeout_add(50, self.timer_cb) self.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.WATCH)) def stop_animating_object(self, object): self.animating_objects.remove(object) if len(self.animating_objects) == 0: if self.timer_id is not None: gobject.source_remove(self.timer_id) self.timer_id = None self.window.set_cursor(gtk.gdk.Cursor(self.cursor)) # Give a chance to solve the problem when animation finishes. self.check_problem_solved() def is_animating(self): return len(self.animating_objects) > 0 def configure_dragging_area(self, grid_size, dragging_rect_width, dragging_rect_height, radial_grid_size): self.GRID_SIZE = grid_size self.DRAGGING_RECT_WIDTH = dragging_rect_width * self.GRID_SIZE self.DRAGGING_RECT_HEIGHT = dragging_rect_height * self.GRID_SIZE self.RADIAL_GRID_SIZE = radial_grid_size def snap_to_grid(self, object): x, y = object.pos.x, object.pos.y angle = object.angle # Convert angle to a positive value while angle < 0: angle = (angle + 2 * math.pi) % (2 * math.pi) # Snap position to grid. x = int((x + self.GRID_SIZE/2 + 1)/ self.GRID_SIZE) * self.GRID_SIZE y = int((y + self.GRID_SIZE/2 + 1)/ self.GRID_SIZE) * self.GRID_SIZE # Snap angle to "radial grid" angle = (int((angle + self.RADIAL_GRID_SIZE/2)/ self.RADIAL_GRID_SIZE) * self.RADIAL_GRID_SIZE) % (2 * math.pi) object.move(Vector(x, y)) object.rotate(angle) def queue_cursor(self, cursor): self.queued_cursor = cursor def on_mouse(self, widget, event): # Wait for animation to finish before accepting input. if self.is_animating(): return # Any mouse movement over the canvas grabs focus, so we can receive keyboard events. if not widget.is_focus(): widget.grab_focus() # Scale the mouse coordinates. event.x = event.x / self.scale_ratio event.y = event.y / self.scale_ratio # Clear the requested cursor. If one of the objects interacts with the mouse, it will # set the queued_cursor. self.queued_cursor = None # If an object is being dragged, it receives all mouse events. if self.drag_object: self.drag_object.on_mouse(event) # Pass event down through objects. for o in reversed(sorted(self.objects, cmp=lambda x,y: x.z-y.z)): #for o in sorted(self.objects, cmp=lambda x,y: x.z-y.z): if o.on_mouse(event): break # Apply the requested cursor. if self.queued_cursor != self.cursor: self.cursor = self.queued_cursor if self.cursor: self.window.set_cursor(gtk.gdk.Cursor(self.cursor)) else: self.window.set_cursor(None) # Give the lesson an opportunity to advance to the next stage, if the problem has been solved # by this movement. if not self.drag_object: self.check_problem_solved() gtk.gdk.event_request_motions(event) def on_key(self, widget, event): # Wait for animation to finish before accepting input. if self.is_animating(): return key_name = gtk.gdk.keyval_name(event.keyval) # Useful print for determining the names of keys. #print key_name # Tab key selects the next object. if key_name == 'Tab' or key_name == 'KP_Home': #print "ObjectArea: on_key: key_name was ", key_name #print " There are: ", len(self.objects), "objects" if len(self.objects): #print " The currently selected object is: ", self.selected_object if self.selected_object: index = self.objects.index(self.selected_object) while True: index = (index + 1) % len(self.objects) if self.objects[index].selectable: self.select_object(self.objects[index]) break else: self.select_object(self.objects[0]) else: # Allow selected object to handle key events. if self.selected_object: self.selected_object.on_key(event) # Give the lesson an opportunity to advance to the next stage, if the problem has been solved # by this movement. self.check_problem_solved() return True def resize_cb(self): # Calculate the ratio to fit the area's coordinate system within the window # while preserving aspect ratio. self.bounds = self.get_allocation() ratio_x = float(self.bounds.width) / AREA_WIDTH ratio_y = float(self.bounds.height) / AREA_HEIGHT self.scale_ratio = min(ratio_x, ratio_y) self.queue_draw() return True def draw_grid(self, cr): cr.set_source_rgb(0, 0, 0) cr.set_line_width(1.0) # Draw the horizontal lines. for i in range(self.DRAGGING_RECT_HEIGHT/self.GRID_SIZE + 1): cr.move_to(0, i * self.GRID_SIZE) cr.line_to(int(self.DRAGGING_RECT_WIDTH/self.GRID_SIZE) * self.GRID_SIZE, i * self.GRID_SIZE) # Draw the vertical lines. for i in range(self.DRAGGING_RECT_WIDTH/self.GRID_SIZE + 1): cr.move_to(i * self.GRID_SIZE, 0) cr.line_to(i * self.GRID_SIZE, int(self.DRAGGING_RECT_HEIGHT/self.GRID_SIZE) * self.GRID_SIZE) cr.stroke() # Derived classes may overide this method to implement special background drawing. def draw_background(self, cr): pass def expose_cb(self, widget, event): # Track changes to the window size. bounds = self.get_allocation() if bounds.width != self.bounds.width or bounds.height != self.bounds.height: self.resize_cb() cr = self.bin_window.cairo_create() cr.rectangle(event.area.x, event.area.y, event.area.width, event.area.height) cr.clip() # Scale to fit. cr.scale(self.scale_ratio, self.scale_ratio) # Draw the grid. if GRID_VISIBLE: self.draw_grid(cr) # Allow the derived class to draw its background. self.draw_background(cr) # Draw objects in Z order. for o in sorted(self.objects, cmp=lambda x,y: x.z-y.z): #for o in reversed(sorted(self.objects, cmp=lambda x,y: x.z-y.z)): bounds = o.get_bounds() rect = gtk.gdk.Rectangle(int(bounds[0].x), int(bounds[0].y), int(bounds[1].x), int(bounds[1].y)) if event.area.intersect(rect): cr.save() cr.rectangle(rect.x, rect.y, rect.width, rect.height) cr.clip() #print "drawing index = ", self.objects.index(o) o.draw(cr) cr.restore() def timer_cb(self): for o in self.animating_objects: o.animate() return True