From 1607ddce227bd26449c85c5d43eebedf598e28b0 Mon Sep 17 00:00:00 2001 From: Simon Poirier Date: Fri, 30 Oct 2009 03:27:19 +0000 Subject: demo fixes --- diff --git a/src/extensions/tutoriusremote.py b/src/extensions/tutoriusremote.py new file mode 100755 index 0000000..b2c37c5 --- /dev/null +++ b/src/extensions/tutoriusremote.py @@ -0,0 +1,66 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Simon Poirier +# +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This modules regroups the UI elements that drives the tutorial and tutorial +creator from the Sugar frame. +""" + +import gtk +from gettext import gettext as _ +import gconf + +from sugar.graphics.tray import TrayIcon +from sugar.graphics.palette import Palette +from sugar.graphics.xocolor import XoColor + +from jarabe.frame.frameinvoker import FrameWidgetInvoker + +_ICON_NAME = 'tutortool' + +class TutoriusRemote(TrayIcon): + + FRAME_POSITION_RELATIVE = 102 + + def __init__(self): + client = gconf.client_get_default() + self._color = XoColor(client.get_string('/desktop/sugar/user/color')) + + super(TutoriusRemote, self).__init__(icon_name=_ICON_NAME, xo_color=self._color) + + self.set_palette_invoker(FrameWidgetInvoker(self)) + + self.palette = TPalette(_('Tutorius')) + self.palette.set_group_id('frame') + +class TPalette(Palette): + def __init__(self, primary_text): + super(TPalette, self).__init__(primary_text) + + self._creator_item = gtk.MenuItem(_('Create a tutorial')) + self._creator_item.connect('activate', self._start_creator) + self._creator_item.show() + self.menu.append(self._creator_item) + + self.set_content(None) + + def _start_creator(self, widget): + self.menu.remove(self._creator_item) + + +def setup(tray): + tray.add_device(TutoriusRemote()) diff --git a/src/tutorius/TProbe.py b/src/tutorius/TProbe.py new file mode 100644 index 0000000..867ef1c --- /dev/null +++ b/src/tutorius/TProbe.py @@ -0,0 +1,504 @@ +import logging +LOGGER = logging.getLogger("sugar.tutorius.TProbe") +import os + +import gobject + +import dbus +import dbus.service +import cPickle as pickle + + +from . import addon +from .services import ObjectStore +from .properties import TPropContainer + +from .dbustools import remote_call, save_args +import copy + +""" + -------------------- + | ProbeManager | + -------------------- + | + V + -------------------- ---------- + | ProbeProxy |<---- DBus ---->| TProbe | + -------------------- ---------- + +""" +#TODO Add stub error handling for remote calls in the classes so that it will +# be clearer how errors can be handled in the future. + + +class TProbe(dbus.service.Object): + """ Tutorius Probe + Defines an entry point for Tutorius into activities that allows + performing actions and registering events onto an activity via + a DBUS Interface. + """ + + def __init__(self, activity_name, activity): + """ + Create and register a TProbe for an activity. + + @param activity_name unique activity_id + @param activity activity reference, must be a gtk container + """ + LOGGER.debug("TProbe :: Creating TProbe for %s (%d)", activity_name, os.getpid()) + LOGGER.debug("TProbe :: Current gobject context: %s", str(gobject.main_context_default())) + LOGGER.debug("TProbe :: Current gobject depth: %s", str(gobject.main_depth())) + # Moving the ObjectStore assignment here, in the meantime + # the reference to the activity shouldn't be share as a + # global variable but passed by the Probe to the objects + # that requires it + self._activity = activity + + ObjectStore().activity = activity + + self._activity_name = activity_name + self._session_bus = dbus.SessionBus() + + # Giving a new name because _name is already used by dbus + self._name2 = dbus.service.BusName(activity_name, self._session_bus) + dbus.service.Object.__init__(self, self._session_bus, "/tutorius/Probe") + + # Add the dictionary we will use to store which actions and events + # are known + self._installedActions = {} + self._subscribedEvents = {} + + def start(self): + """ + Optional method to call if the probe is not inserted into an + existing activity. Starts a gobject mainloop + """ + mainloop = gobject.MainLoop() + print "Starting Probe for " + self._activity_name + mainloop.run() + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='') + def registered(self, service): + print ("Registered with: " + str(service)) + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='', out_signature='s') + def ping(self): + """ + Allows testing the connection to a Probe + @return string "alive" + """ + return "alive" + + # ------------------ Action handling -------------------------------------- + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='s') + def install(self, pickled_action): + """ + Install an action on the Activity + @param pickled_action string pickled action + @return string address of installed action + """ + loaded_action = pickle.loads(str(pickled_action)) + action = addon.create(loaded_action.__class__.__name__) + + address = self._generate_action_reference(action) + + self._installedActions[address] = action + + if action._props: + action._props.update(loaded_action._props) + + action.do() + + return address + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='ss', out_signature='') + def update(self, address, action_props): + """ + Update an already registered action + @param address string address returned by install() + @param action_props pickled action properties + @return None + """ + action = self._installedActions[address] + + if action._props: + props = pickle.loads(str(action_props)) + action._props.update(props) + action.undo() + action.do() + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='') + def uninstall(self, address): + """ + Uninstall an action + @param address string address returned by install() + @return None + """ + if self._installedActions.has_key(address): + action = self._installedActions[address] + action.undo() + self._installedActions.pop(address) + + + # ------------------ Event handling --------------------------------------- + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='s') + def subscribe(self, pickled_event): + """ + Subscribe to an Event + @param pickled_event string pickled EventFilter + @return string unique name of registered event + """ + #TODO Perform event unmapping once Tutorials use abstract events + # instead of concrete EventFilters that are tied to their + # implementation. + eventfilter = pickle.loads(str(pickled_event)) + + # The callback uses the event defined previously and each + # successive call to subscribe will register a different + # callback that references a different event + def callback(*args): + self.notify(eventfilter) + + eventfilter.install_handlers(callback, activity=self._activity) + + name = self._generate_event_reference(eventfilter) + self._subscribedEvents[name] = eventfilter + + return name + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='') + def unsubscribe(self, address): + """ + Remove subscription to an event + @param address string adress returned by subscribe() + @return None + """ + + if self._subscribedEvents.has_key(address): + eventfilter = self._subscribedEvents[address] + eventfilter.remove_handlers() + self._subscribedEvents.pop(address) + + @dbus.service.signal("org.tutorius.ProbeInterface") + def eventOccured(self, event): + # We need no processing now, the signal will be sent + # when the method exit + pass + + # The actual method we will call on the probe to send events + def notify(self, event): + LOGGER.debug("TProbe :: notify event %s", str(event)) + self.eventOccured(pickle.dumps(event)) + + # Return a unique name for this action + def _generate_action_reference(self, action): + # TODO elavoie 2009-07-25 Should return a universal address + name = action.__class__.__name__ + suffix = 1 + + while self._installedActions.has_key(name+str(suffix)): + suffix += 1 + + return name + str(suffix) + + + # Return a unique name for this event + def _generate_event_reference(self, event): + # TODO elavoie 2009-07-25 Should return a universal address + name = event.__class__.__name__ + #Keep the counter to avoid looping all the time + suffix = getattr(self, '_event_ref_suffix', 0 ) + 1 + + while self._subscribedEvents.has_key(name+str(suffix)): + suffix += 1 + + #setattr(self, '_event_ref_suffix', suffix) + + return name + str(suffix) + +class ProbeProxy: + """ + ProbeProxy is a Proxy class for connecting to a remote TProbe. + + It provides an object interface to the TProbe, which requires pickled + strings, across a DBus communication. + """ + def __init__(self, activityName): + """ + Constructor + @param activityName unique activity id. Must be a valid dbus bus name. + """ + LOGGER.debug("ProbeProxy :: Creating ProbeProxy for %s (%d)", activityName, os.getpid()) + LOGGER.debug("ProbeProxy :: Current gobject context: %s", str(gobject.main_context_default())) + LOGGER.debug("ProbeProxy :: Current gobject depth: %s", str(gobject.main_depth())) + bus = dbus.SessionBus() + self._object = bus.get_object(activityName, "/tutorius/Probe") + self._probe = dbus.Interface(self._object, "org.tutorius.ProbeInterface") + + self._actions = {} + # We keep those two data structures to be able to have multiple callbacks + # for the same event and be able to remove them independently + # _subscribedEvents holds a list of callback addresses's for each event + # _registeredCallbacks holds the functions to call for each address + self._subscribedEvents = {} + self._registeredCallbacks = {} + + + self._object.connect_to_signal("eventOccured", self._handle_signal, dbus_interface="org.tutorius.ProbeInterface") + + def _handle_signal(self, pickled_event): + event = pickle.loads(str(pickled_event)) + LOGGER.debug("ProbeProxy :: Received Event : %s %s", str(event), str(event._props.items())) + + LOGGER.debug("ProbeProxy :: Currently %d events registered", len(self._registeredCallbacks)) + if self._registeredCallbacks.has_key(event): + for callback in self._registeredCallbacks[event].values(): + callback(event) + else: + for event in self._registeredCallbacks.keys(): + LOGGER.debug("==== %s", str(event._props.items())) + LOGGER.debug("ProbeProxy :: Event does not appear to be registered") + + def isAlive(self): + try: + return self._probe.ping() == "alive" + except: + return False + + def __update_action(self, action, address): + LOGGER.debug("ProbeProxy :: Updating action %s with address %s", str(action), str(address)) + self._actions[action] = str(address) + + def __clear_action(self, action): + self._actions.pop(action, None) + + def install(self, action, block=False): + """ + Install an action on the TProbe's activity + @param action Action to install + @param block Force a synchroneous dbus call if True + @return None + """ + return remote_call(self._probe.install, (pickle.dumps(action),), + save_args(self.__update_action, action), + block=block) + + def update(self, action, newaction, block=False): + """ + Update an already installed action's properties and run it again + @param action Action to update + @param newaction Action to update it with + @param block Force a synchroneous dbus call if True + @return None + """ + #TODO review how to make this work well + if not action in self._actions: + raise RuntimeWarning("Action not installed") + #TODO Check error handling + return remote_call(self._probe.update, (self._actions[action], pickle.dumps(newaction._props)), block=block) + + def uninstall(self, action, block=False): + """ + Uninstall an installed action + @param action Action to uninstall + @param block Force a synchroneous dbus call if True + """ + if action in self._actions: + remote_call(self._probe.uninstall,(self._actions.pop(action),), block=block) + + def __update_event(self, event, callback, address): + LOGGER.debug("ProbeProxy :: Registered event %s with address %s", str(hash(event)), str(address)) + # Since multiple callbacks could be associated to the same + # event signature, we will store multiple callbacks + # in a dictionary indexed by the unique address + # given for this subscribtion and access this + # dictionary from another one indexed by event + address = str(address) + + # We use the event object as a key + if not self._registeredCallbacks.has_key(event): + self._registeredCallbacks[event] = {} + + # TODO elavoie 2009-07-25 decide on a proper exception + # taxonomy + if self._registeredCallbacks[event].has_key(address): + # Oups, how come we have two similar addresses? + # send the bad news! + raise Exception("Probe subscribe exception, the following address already exists: " + str(address)) + + self._registeredCallbacks[event][address] = callback + + # We will keep another dictionary to remember the + # event that was associated to this unique address + # Let's copy to make sure that even if the event + # passed in is modified later it won't screw up + # our dictionary (python pass arguments by reference) + self._subscribedEvents[address] = copy.copy(event) + + return address + + def __clear_event(self, address): + LOGGER.debug("ProbeProxy :: Unregistering adress %s", str(address)) + # Cleanup everything + if self._subscribedEvents.has_key(address): + event = self._subscribedEvents[address] + + if self._registeredCallbacks.has_key(event)\ + and self._registeredCallbacks[event].has_key(address): + self._registeredCallbacks[event].pop(address) + + if self._registeredCallbacks[event] == {}: + self._registeredCallbacks.pop(event) + + self._subscribedEvents.pop(address) + else: + LOGGER.debug("ProbeProxy :: unsubsribe address %s inconsistency : not registered", address) + + def subscribe(self, event, callback, block=True): + """ + Register an event listener + @param event Event to listen for + @param callback callable that will be called when the event occurs + @param block Force a synchroneous dbus call if True (Not allowed yet) + @return address identifier used for unsubscribing + """ + LOGGER.debug("ProbeProxy :: Registering event %s", str(hash(event))) + if not block: + raise RuntimeError("This function does not allow non-blocking mode yet") + + # TODO elavoie 2009-07-25 When we will allow for patterns both + # for event types and sources, we will need to revise the lookup + # mecanism for which callback function to call + return remote_call(self._probe.subscribe, (pickle.dumps(event),), + save_args(self.__update_event, event, callback), + block=block) + + def unsubscribe(self, address, block=True): + """ + Unregister an event listener + @param address identifier given by subscribe() + @param block Force a synchroneous dbus call if True + @return None + """ + LOGGER.debug("ProbeProxy :: Unregister adress %s issued", str(address)) + if address in self._subscribedEvents.keys(): + remote_call(self._probe.unsubscribe, (address,), + return_cb=save_args(self.__clear_event, address), + block=block) + else: + LOGGER.debug("ProbeProxy :: unsubsribe address %s failed : not registered", address) + + def detach(self, block=False): + """ + Detach the ProbeProxy from it's TProbe. All installed actions and + subscribed events should be removed. + """ + for action in self._actions.keys(): + self.uninstall(action, block) + + for address in self._subscribedEvents.keys(): + self.unsubscribe(address, block) + + +class ProbeManager(object): + """ + The ProbeManager provides multiplexing across multiple activity ProbeProxies + + For now, it only handles one at a time, though. + Actually it doesn't do much at all. But it keeps your encapsulation happy + """ + def __init__(self): + self._probes = {} + self._current_activity = None + + def setCurrentActivity(self, activity_id): + if not activity_id in self._probes: + raise RuntimeError("Activity not attached") + self._current_activity = activity_id + + def getCurrentActivity(self): + return self._current_activity + + currentActivity = property(fget=getCurrentActivity, fset=setCurrentActivity) + def attach(self, activity_id): + if activity_id in self._probes: + raise RuntimeWarning("Activity already attached") + + self._probes[activity_id] = ProbeProxy(activity_id) + #TODO what do we do with this? Raise something? + if self._probes[activity_id].isAlive(): + print "Alive!" + else: + print "FAil!" + + def detach(self, activity_id): + if activity_id in self._probes: + probe = self._probes.pop(activity_id) + probe.detach() + + def install(self, action, block=False): + """ + Install an action on the current activity + @param action Action to install + @param block Force a synchroneous dbus call if True + @return None + """ + if self.currentActivity: + return self._probes[self.currentActivity].install(action, block) + else: + raise RuntimeWarning("No activity attached") + + def update(self, action, newaction, block=False): + """ + Update an already installed action's properties and run it again + @param action Action to update + @param newaction Action to update it with + @param block Force a synchroneous dbus call if True + @return None + """ + if self.currentActivity: + return self._probes[self.currentActivity].update(action, newaction, block) + else: + raise RuntimeWarning("No activity attached") + + def uninstall(self, action, block=False): + """ + Uninstall an installed action + @param action Action to uninstall + @param block Force a synchroneous dbus call if True + """ + if self.currentActivity: + return self._probes[self.currentActivity].uninstall(action, block) + else: + raise RuntimeWarning("No activity attached") + + def subscribe(self, event, callback): + """ + Register an event listener + @param event Event to listen for + @param callback callable that will be called when the event occurs + @return address identifier used for unsubscribing + """ + if self.currentActivity: + return self._probes[self.currentActivity].subscribe(event, callback) + else: + raise RuntimeWarning("No activity attached") + + def unsubscribe(self, address): + """ + Unregister an event listener + @param address identifier given by subscribe() + @return None + """ + if self.currentActivity: + return self._probes[self.currentActivity].unsubscribe(address) + else: + raise RuntimeWarning("No activity attached") + diff --git a/src/tutorius/__init__.py b/src/tutorius/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/tutorius/__init__.py diff --git a/src/tutorius/actions.py b/src/tutorius/actions.py new file mode 100644 index 0000000..bb15459 --- /dev/null +++ b/src/tutorius/actions.py @@ -0,0 +1,178 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module defines Actions that can be done and undone on a state +""" +import gtk + +from gettext import gettext as _ + +from sugar.graphics import icon + +from . import addon +from .services import ObjectStore +from .properties import * + +class DragWrapper(object): + """Wrapper to allow gtk widgets to be dragged around""" + def __init__(self, widget, position, draggable=False): + """ + Creates a wrapper to allow gtk widgets to be mouse dragged, if the + parent container supports the move() method, like a gtk.Layout. + @param widget the widget to enhance with drag capability + @param position the widget's position. Will translate the widget if needed + @param draggable wether to enable the drag functionality now + """ + self._widget = widget + self._eventbox = None + self._drag_on = False # whether dragging is enabled + self._rel_pos = (0,0) # mouse pos relative to widget + self._handles = [] # event handlers + self._dragging = False # whether a drag is in progress + self.position = position # position of the widget + + self.draggable = draggable + + def _pressed_cb(self, widget, evt): + """Callback for start of drag event""" + self._eventbox.grab_add() + self._dragging = True + self._rel_pos = evt.get_coords() + + def _moved_cb(self, widget, evt): + """Callback for mouse drag events""" + if not self._dragging: + return + + # Focus on a widget before dragging another would + # create addititonal move event, making the widget jump unexpectedly. + # Solution found was to process those focus events before dragging. + if gtk.events_pending(): + return + + xrel, yrel = self._rel_pos + xparent, yparent = evt.get_coords() + xparent, yparent = widget.translate_coordinates(widget.parent, + xparent, yparent) + self.position = (xparent-xrel, yparent-yrel) + self._widget.parent.move(self._eventbox, *self.position) + self._widget.parent.move(self._widget, *self.position) + self._widget.parent.queue_draw() + + def _released_cb(self, *args): + """Callback for end of drag (mouse release).""" + self._eventbox.grab_remove() + self._dragging = False + + def _drag_end(self, *args): + """Callback for end of drag (stolen focus).""" + self._dragging = False + + def set_draggable(self, value): + """Setter for the draggable property""" + if bool(value) ^ bool(self._drag_on): + if value: + self._eventbox = gtk.EventBox() + self._eventbox.show() + self._eventbox.set_visible_window(False) + size = self._widget.size_request() + self._eventbox.set_size_request(*size) + self._widget.parent.put(self._eventbox, *self.position) + self._handles.append(self._eventbox.connect( + "button-press-event", self._pressed_cb)) + self._handles.append(self._eventbox.connect( + "button-release-event", self._released_cb)) + self._handles.append(self._eventbox.connect( + "motion-notify-event", self._moved_cb)) + self._handles.append(self._eventbox.connect( + "grab-broken-event", self._drag_end)) + else: + while len(self._handles): + handle = self._handles.pop() + self._eventbox.disconnect(handle) + self._eventbox.parent.remove(self._eventbox) + self._eventbox.destroy() + self._eventbox = None + self._drag_on = value + + def get_draggable(self): + """Getter for the draggable property""" + return self._drag_on + + draggable = property(fset=set_draggable, fget=get_draggable, \ + doc="Property to enable the draggable behaviour of the widget") + + def set_widget(self, widget): + """Setter for the widget property""" + if self._dragging or self._drag_on: + raise Exception("Can't change widget while dragging is enabled.") + + assert hasattr(widget, "parent"), "wrapped widget should have a parent" + parent = widget.parent + assert hasattr(parent, "move"), "container of widget need move method" + self._widget = widget + + def get_widget(self): + """Getter for the widget property""" + return self._widget + + widget = property(fset=set_widget, fget=get_widget) + +class Action(TPropContainer): + """Base class for Actions""" + def __init__(self): + TPropContainer.__init__(self) + self.position = (0,0) + self._drag = None + + def do(self, **kwargs): + """ + Perform the action + """ + raise NotImplementedError("Not implemented") + + def undo(self): + """ + Revert anything the action has changed + """ + pass #Should raise NotImplemented? + + def enter_editmode(self, **kwargs): + """ + Enters edit mode. The action should display itself in some way, + without affecting the currently running application. The default is + a small box with the action icon. + """ + meta = addon.get_addon_meta(type(self).__name__) + + actionicon = icon.Icon(icon_name=meta['icon'], + icon_size=gtk.ICON_SIZE_LARGE_TOOLBAR) + # Eventbox create a visible window for the icon, so it clips correctly + self.__edit_img = gtk.EventBox() + self.__edit_img.set_visible_window(True) + self.__edit_img.add(actionicon) + + x, y = self.position + + ObjectStore().activity._overlayer.put(self.__edit_img, x, y) + self.__edit_img.show_all() + self._drag = DragWrapper(self.__edit_img, self.position, True) + + def exit_editmode(self, **kwargs): + x, y = self._drag.position + self.position = [int(x), int(y)] + self.__edit_img.destroy() + diff --git a/src/tutorius/addon.py b/src/tutorius/addon.py new file mode 100644 index 0000000..7ac68f7 --- /dev/null +++ b/src/tutorius/addon.py @@ -0,0 +1,95 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Simon Poirier +# +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module manages the loading and listing of tutorius addons. +Addons are modular actions and events that are package in such a way that they +can be autodetected and can integrate with Tutorius components (the editor) +without any configuration or explicit dependencies (python import). + +An action addon is expected to have a metadata dict such as this one: +__action__ = { + "name" : "HelloWorld", + "display_name" : "Hello World!", + "icon" : "hello", + "class" : HelloAction, + "mandatory_props" : ["text"], +} +""" + +import os +import re +import logging + +PREFIX = __name__+"s" +PATH = re.sub("addon\\.py[c]$", "", __file__)+"addons" + +TYPE_ACTION = 'action' +TYPE_EVENT = 'event' + +_cache = None + +def _reload_addons(): + global _cache + _cache = {} + for addon in filter(lambda x: x.endswith("py"), os.listdir(PATH)): + mod = __import__(PREFIX+'.'+re.sub("\\.py$", "", addon), {}, {}, [""]) + if hasattr(mod, "__action__"): + _cache[mod.__action__['name']] = mod.__action__ + mod.__action__['type'] = TYPE_ACTION + continue + if hasattr(mod, "__event__"): + _cache[mod.__event__['name']] = mod.__event__ + mod.__event__['type'] = TYPE_EVENT + +def create(name, *args, **kwargs): + global _cache + if not _cache: + _reload_addons() + try: + comp_metadata = _cache[name] + try: + return comp_metadata['class'](*args, **kwargs) + except: + logging.error("Could not instantiate %s with parameters %s, %s"%(comp_metadata['name'],str(args), str(kwargs))) + return None + except KeyError: + logging.error("Addon not found for class '%s'", name) + return None + +def list_addons(): + global _cache + if not _cache: + _reload_addons() + return _cache.keys() + +def get_addon_meta(name): + global _cache + if not _cache: + _reload_addons() + return _cache[name] + +def get_name_from_type(typ): + global _cache + if not _cache: + _reload_addons() + for addon in _cache.keys(): + if typ == _cache[addon]['class']: + return addon + return None + +# vim:set ts=4 sts=4 sw=4 et: diff --git a/src/tutorius/constraints.py b/src/tutorius/constraints.py new file mode 100644 index 0000000..e91f23a --- /dev/null +++ b/src/tutorius/constraints.py @@ -0,0 +1,210 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Constraints + +Defines a set of constraints with their related errors. These constraints are +made to be used inside TutoriusProperties in order to limit the values that +they might take. They can also be used to enforce a particular format or type +for some properties. +""" + +# For the File Constraint +import os + +class Constraint(): + """ + Basic block for defining constraints on a TutoriusProperty. Every class + inheriting from Constraint will have a validate function that will be + executed when the property's value is to be changed. + """ + def validate(self, value): + """ + This function receives the value that is proposed as a new value for + the property. It needs to raise an Error in the case where the value + does not respect this constraint. + """ + raise NotImplementedError("Unable to validate a base Constraint") + +class ValueConstraint(Constraint): + """ + A value constraint contains a _limit member that can be used in a children + class as a basic value. See UpperLimitConstraint for an exemple. + """ + def __init__(self, limit): + self.limit = limit + +class UpperLimitConstraintError(Exception): + pass + +class UpperLimitConstraint(ValueConstraint): + def validate(self, value): + """ + Evaluates whether the given value is smaller than the limit. + + @raise UpperLimitConstraintError When the value is strictly higher than + the limit. + """ + if self.limit is not None: + if self.limit >= value: + return + raise UpperLimitConstraintError() + return + +class LowerLimitConstraintError(Exception): + pass + +class LowerLimitConstraint(ValueConstraint): + def validate(self, value): + """ + If the value is lower than the limit, this function raises an error. + + @raise LowerLimitConstraintError When the value is strictly smaller + than the limit. + """ + if self.limit is not None: + if value >= self.limit: + return + raise LowerLimitConstraintError() + return + +class MaxSizeConstraintError(Exception): + pass + +class MaxSizeConstraint(ValueConstraint): + def validate(self, value): + """ + Evaluate whether a given object is smaller than the given size when + run through len(). Great for string, lists and the like. ;) + + @raise SizeConstraintError If the length of the value is strictly + bigger than the limit. + """ + if self.limit is not None: + if self.limit >= len(value): + return + raise MaxSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit)) + return + +class MinSizeConstraintError(Exception): + pass + +class MinSizeConstraint(ValueConstraint): + def validate(self, value): + """ + Evaluate whether a given object is smaller than the given size when + run through len(). Great for string, lists and the like. ;) + + @raise SizeConstraintError If the length of the value is strictly + bigger than the limit. + """ + if self.limit is not None: + if self.limit <= len(value): + return + raise MinSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit)) + return + +class ColorConstraintError(Exception): + pass + +class ColorArraySizeError(ColorConstraintError): + pass + +class ColorTypeError(ColorConstraintError): + pass + +class ColorValueError(ColorConstraintError): + pass + +class ColorConstraint(Constraint): + """ + Validates that the value is an array of size 3 with three numbers between + 0 and 255 (inclusively) in it. + + """ + def validate(self, value): + if len(value) != 3: + raise ColorArraySizeError("The value is not an array of size 3") + + if not (type(value[0]) == type(22) and type(value[1]) == type(22) and type(value[2]) == type(22)): + raise ColorTypeError("Not all the elements of the array are integers") + + if value[0] > 255 or value[0] <0: + raise ColorValueError("Red value is not between 0 and 255") + + if value[1] > 255 or value[1] <0: + raise ColorValueError("Green value is not between 0 and 255") + + if value[2] > 255 or value[2] <0: + raise ColorValueError("Blue value is not between 0 and 255") + + return + +class BooleanConstraintError(Exception): + pass + +class BooleanConstraint(Constraint): + """ + Validates that the value is either True or False. + """ + def validate(self, value): + if value == True or value == False: + return + raise BooleanConstraintError("Value is not True or False") + +class EnumConstraintError(Exception): + pass + +class EnumConstraint(Constraint): + """ + Validates that the value is part of a set of well-defined values. + """ + def __init__(self, accepted_values): + """ + Creates the constraint and stores the list of accepted values. + + @param correct_values A list that contains all the values that will + be declared as satisfying the constraint + """ + self._accepted_values = accepted_values + + def validate(self, value): + """ + Ensures that the value that is passed is part of the list of accepted + values. + """ + if not value in self._accepted_values: + raise EnumConstraintError("Value is not part of the enumeration") + return + +class FileConstraintError(Exception): + pass + +class FileConstraint(Constraint): + """ + Ensures that the string given corresponds to an existing file on disk. + """ + def validate(self, value): + # TODO : Decide on the architecture for file retrieval on disk + # Relative paths? From where? Support macros? + # FIXME This is a hack to make cases where a default file is not valid + # work. It allows None values to be validated, though + if value is None: + return + if not os.path.isfile(value): + raise FileConstraintError("Non-existing file : %s"%value) + return + diff --git a/src/tutorius/core.py b/src/tutorius/core.py new file mode 100644 index 0000000..bfbe07b --- /dev/null +++ b/src/tutorius/core.py @@ -0,0 +1,618 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Core + +This module contains the core classes for tutorius + +""" + +import logging +import os + +from .TProbe import ProbeManager +from .dbustools import save_args +from . import addon + +logger = logging.getLogger("tutorius") + +class Tutorial (object): + """ + Tutorial Class, used to run through the FSM. + """ + #Properties + probeManager = property(lambda self: self._probeMgr) + activityId = property(lambda self: self._activity_id) + + def __init__(self, name, fsm, filename=None): + """ + Creates an unattached tutorial. + """ + object.__init__(self) + self.name = name + self.activity_init_state_filename = filename + + self.state_machine = fsm + self.state_machine.set_tutorial(self) + + self.state = None + + self.handlers = [] + self._probeMgr = ProbeManager() + self._activity_id = None + #Rest of initialisation happens when attached + + def attach(self, activity_id): + """ + Attach to a running activity + + @param activity_id the id of the activity to attach to + """ + #For now, absolutely detach if a previous one! + if self._activity_id: + self.detach() + self._activity_id = activity_id + self._probeMgr.attach(activity_id) + self._probeMgr.currentActivity = activity_id + self._prepare_activity() + self.state_machine.set_state("INIT") + + def detach(self): + """ + Detach from the current activity + """ + + # Uninstall the whole FSM + self.state_machine.teardown() + + if not self._activity_id is None: + self._probeMgr.detach(self._activity_id) + self._activity_id = None + + def set_state(self, name): + """ + Switch to a new state + """ + logger.debug("==== NEW STATE: %s ====" % name) + + self.state_machine.set_state(name) + + def _prepare_activity(self): + """ + Prepare the activity for the tutorial by loading the saved state and + emitting gtk signals + """ + #Load the saved activity if any + if self.activity_init_state_filename is not None: + #For now the file will be saved in the data folder + #of the activity root directory + filename = os.getenv("SUGAR_ACTIVITY_ROOT") + "/data/" +\ + self.activity_init_state_filename + readfile = addon.create("ReadFile", filename=filename) + if readfile: + self._probeMgr.install(readfile) + #Uninstall now while we have the reference handy + self._probeMgr.uninstall(readfile) + +class State(object): + """ + This is a step in a tutorial. The state represents a collection of actions + to undertake when entering the state, and a series of event filters + with associated actions that point to a possible next state. + """ + + def __init__(self, name="", action_list=None, event_filter_list=None, tutorial=None): + """ + Initializes the content of the state, like loading the actions + that are required and building the correct tests. + + @param action_list The list of actions to execute when entering this + state + @param event_filter_list A list of tuples of the form + (event_filter, next_state_name), that explains the outgoing links for + this state + @param tutorial The higher level container of the state + """ + object.__init__(self) + + self.name = name + + self._actions = action_list or [] + + self._transitions= dict(event_filter_list or []) + + self._installedEvents = set() + + self.tutorial = tutorial + + def set_tutorial(self, tutorial): + """ + Associates this state with a tutorial. A tutorial must be set prior + to executing anything in the state. The reason for this is that the + states need to have access to the activity (via the tutorial) in order + to properly register their callbacks on the activities' widgets. + + @param tutorial The tutorial that this state runs under. + """ + if self.tutorial == None : + self.tutorial = tutorial + else: + raise RuntimeWarning(\ + "The state %s was already associated with a tutorial." % self.name) + + def setup(self): + """ + Install the state itself, by first registering the event filters + and then triggering the actions. + """ + for (event, next_state) in self._transitions.items(): + self._installedEvents.add(self.tutorial.probeManager.subscribe(event, save_args(self._event_filter_state_done_cb, next_state ))) + + for action in self._actions: + self.tutorial.probeManager.install(action) + + def teardown(self): + """ + Uninstall all the event filters that were active in this state. + Also undo every action that was installed for this state. This means + removing dialogs that were displayed, removing highlights, etc... + """ + # Remove the handlers for the all of the state's event filters + while len(self._installedEvents) > 0: + self.tutorial.probeManager.unsubscribe(self._installedEvents.pop()) + + # Undo all the actions related to this state + for action in self._actions: + self.tutorial.probeManager.uninstall(action) + + def _event_filter_state_done_cb(self, next_state, event): + """ + Callback for event filters. This function needs to inform the + tutorial that the state is over and tell it what is the next state. + + @param next_state The next state for the transition + @param event The event that occured + """ + # Run the tests here, if need be + + # Warn the higher level that we wish to change state + self.tutorial.set_state(next_state) + + # Model manipulation + # These functions are used to simplify the creation of states + def add_action(self, new_action): + """ + Adds an action to the state + + @param new_action The new action to execute when in this state + @return True if added, False otherwise + """ + self._actions.append(new_action) + return True + + # remove_action - We did not define names for the action, hence they're + # pretty hard to remove on a precise basis + + def get_action_list(self): + """ + @return A list of actions that the state will execute + """ + return self._actions + + def clear_actions(self): + """ + Removes all the action associated with this state. A cleared state will + not do anything when entered or exited. + """ + #FIXME What if the action is currently installed? + self._actions = [] + + def add_event_filter(self, event, next_state): + """ + Adds an event filter that will cause a transition from this state. + + The same event filter may not be added twice. + + @param event The event that will trigger a transition + @param next_state The state to which the transition will lead + @return True if added, False otherwise + """ + if event not in self._transitions.keys(): + self._transitions[event]=next_state + return True + return False + + def get_event_filter_list(self): + """ + @return The list of event filters associated with this state. + """ + return self._transitions.items() + + def clear_event_filters(self): + """ + Removes all the event filters associated with this state. A state that + was just cleared will become a sink and will be the end of the + tutorial. + """ + self._transitions = {} + + def __eq__(self, otherState): + """ + Compares two states and tells whether they contain the same states with the + same actions and event filters. + + @param otherState The other State that we wish to match + @returns True if every action in this state has a matching action in the + other state with the same properties and values AND if every + event filters in this state has a matching filter in the + other state having the same properties and values AND if both + states have the same name. +` """ + if not isinstance(otherState, State): + return False + if self.name != otherState.name: + return False + + # Do they have the same actions? + if len(self._actions) != len(otherState._actions): + return False + + if len(self._transitions) != len(otherState._transitions): + return False + + for act in self._actions: + found = False + # For each action in the other state, try to match it with this one. + for otherAct in otherState._actions: + if act == otherAct: + found = True + break + if found == False: + # If we arrive here, then we could not find an action with the + # same values in the other state. We know they're not identical + return False + + # Do they have the same event filters? + if self._transitions != otherState._transitions: + return False + + # If nothing failed up to now, then every actions and every filters can + # be found in the other state + return True + +class FiniteStateMachine(State): + """ + This is a collection of states, with a start state and an end callback. + It is used to simplify the development of the various tutorials by + encapsulating a collection of states that represent a given learning + process. + + For now, we will consider that there can only be states + inserted in the FSM, and that there are no nested FSM inside. + """ + + def __init__(self, name, tutorial=None, state_dict=None, start_state_name="INIT", action_list=None): + """ + The constructor for a FSM. Pass in the start state and the setup + actions that need to be taken when the FSM itself start (which may be + different from what is done in the first state of the machine). + + @param name A short descriptive name for this FSM + @param tutorial The tutorial that will execute this FSM. If None is + attached on creation, then one must absolutely be attached before + executing the FSM with set_tutorial(). + @param state_dict A dictionary containing the state names as keys and + the state themselves as entries. + @param start_state_name The name of the starting state, if different + from "INIT" + @param action_list The actions to undertake when initializing the FSM + """ + State.__init__(self, name) + + self.name = name + self.tutorial = tutorial + + # Dictionnary of states contained in the FSM + self._states = state_dict or {} + + self.start_state_name = start_state_name + # Set the current state to None - we are not executing anything yet + self.current_state = None + + # Register the actions for the FSM - They will be processed at the + # FSM level, meaning that when the FSM will start, it will first + # execute those actions. When the FSM closes, it will tear down the + # inner actions of the state, then close its own actions + self.actions = action_list or [] + + # Flag to mention that the FSM was initialized + self._fsm_setup_done = False + # Flag that must be raised when the FSM is to be teared down + self._fsm_teardown_done = False + # Flag used to declare that the FSM has reached an end state + self._fsm_has_finished = False + + def set_tutorial(self, tutorial): + """ + This associates the FSM to the given tutorial. It MUST be associated + either in the constructor or with this function prior to executing the + FSM. + + @param tutorial The tutorial that will execute this FSM. + """ + # If there was no tutorial associated + if self.tutorial == None: + # Associate it with this FSM and all the underlying states + self.tutorial = tutorial + for state in self._states.itervalues(): + state.set_tutorial(tutorial) + else: + raise RuntimeWarning(\ + "The FSM %s is already associated with a tutorial."%self.name) + + def setup(self): + """ + This function initializes the FSM the first time it is called. + Then, every time it is called, it initializes the current state. + """ + # Are we associated with a tutorial? + if self.tutorial == None: + raise UnboundLocalError("No tutorial was associated with FSM %s" % self.name) + + # If we never initialized the FSM itself, then we need to run all the + # actions associated with the FSM. + if self._fsm_setup_done == False: + # Remember the initial state - we might want to reset + # or rewind the FSM at a later moment + self.start_state = self._states[self.start_state_name] + self.current_state = self.start_state + # Flag the FSM level setup as done + self._fsm_setup_done = True + # Execute all the FSM level actions + for action in self.actions: + self.tutorial.probeManager.install(action) + + # Then, we need to run the setup of the current state + self.current_state.setup() + + def set_state(self, new_state_name): + """ + This functions changes the current state of the finite state machine. + + @param new_state The identifier of the state we need to go to + """ + # TODO : Since we assume no nested FSMs, we don't set state on the + # inner States / FSMs +## # Pass in the name to the internal state - it might be a FSM and +## # this name will apply to it +## self.current_state.set_state(new_state_name) + + # Make sure the given state is owned by the FSM + if not self._states.has_key(new_state_name): + # If we did not recognize the name, then we do not possess any + # state by that name - we must ignore this state change request as + # it will be done elsewhere in the hierarchy (or it's just bogus). + return + + if self.current_state != None: + if new_state_name == self.current_state.name: + # If we already are in this state, we do not need to change + # anything in the current state - By design, a state may not point + # to itself + return + + new_state = self._states[new_state_name] + + # Undo the actions of the old state + self.teardown() + + # Insert the new state + self.current_state = new_state + + # Call the initial actions in the new state + self.setup() + + def get_current_state_name(self): + """ + Returns the name of the current state. + + @return A string representing the name of the current state + """ + return self.current_state.name + + def teardown(self): + """ + Revert any changes done by setup() + """ + # Teardown the current state + if self.current_state is not None: + self.current_state.teardown() + + # If we just finished the whole FSM, we need to also call the teardown + # on the FSM level actions + if self._fsm_has_finished == True: + # Flag the FSM teardown as not needed anymore + self._fsm_teardown_done = True + # Undo all the FSM level actions here + for action in self.actions: + self.tutorial.probeManager.uninstall(action) + + # TODO : It might be nice to have a start() and stop() method for the + # FSM. + + # Data manipulation section + # These functions are dedicated to the building and editing of a graph. + def add_state(self, new_state): + """ + Inserts a new state in the FSM. + + @param new_state The State object that will now be part of the FSM + @raise KeyError In the case where a state with this name already exists + """ + if self._states.has_key(new_state.name): + raise KeyError("There is already a state by this name in the FSM") + + self._states[new_state.name] = new_state + + # Not such a great name for the state accessor... We already have a + # set_state name, so get_state would conflict with the notion of current + # state - I would recommend having a set_current_state instead. + def get_state_by_name(self, state_name): + """ + Fetches a state from the FSM, based on its name. If there is no + such state, the method will throw a KeyError. + + @param state_name The name of the desired state + @return The State object having the given name + """ + return self._states[state_name] + + def remove_state(self, state_name): + """ + Removes a state from the FSM. Raises a KeyError when the state is + not existent. + + Warning : removing a state will also remove all the event filters that + point to this given name, to preserve the FSM's integrity. If you only + want to edit a state, you would be better off fetching this state with + get_state_by_name(). + + @param state_name A string being the name of the state to remove + @raise KeyError When the state_name does not a represent a real state + stored in the dictionary + """ + + state_to_remove = self._states[state_name] + + # Remove the state from the states' dictionnary + for st in self._states.itervalues(): + # Iterate through the list of event filters and remove those + # that point to the state that will be removed + + #TODO : Move this code inside the State itself - we're breaking + # encap :P + for event in st._transitions: + if st._transitions[event] == state_name: + del st._transitions[event] + + # Remove the state from the dictionary + del self._states[state_name] + + # Exploration methods - used to know more about a given state + def get_following_states(self, state_name): + """ + Returns a tuple of the names of the states that point to the given + state. If there is no such state, the function raises a KeyError. + + @param state_name The name of the state to analyse + @raise KeyError When there is no state by this name in the FSM + """ + state = self._states[state_name] + + next_states = set() + + for event, state in state._transitions.items(): + next_states.add(state) + + return tuple(next_states) + + def get_previous_states(self, state_name): + """ + Returns a tuple of the names of the state that can transition to + the given state. If there is no such state, the function raises a + KeyError. + + @param state_name The name of the state that the returned states might + transition to. + """ + # This might seem a bit funny, but we don't verify if the given + # state is present or not in the dictionary. + # This is due to the fact that when building a graph, we might have a + # prototypal state that has not been inserted yet. We could not know + # which states are pointing to it until we insert it in the graph. + + states = [] + # Walk through the list of states + for st in self._states.itervalues(): + for event, state in st._transitions.items(): + if state == state_name: + states.append(state) + continue + + return tuple(states) + + # Convenience methods to see the content of a FSM + def __str__(self): + out_string = "" + for st in self._states.itervalues(): + out_string += st.name + ", " + return out_string + + def __eq__(self, otherFSM): + """ + Compares the elements of two FSM to ensure and returns true if they have the + same set of states, containing the same actions and the same event filters. + + @returns True if the two FSMs have the same content, False otherwise + """ + if not isinstance(otherFSM, FiniteStateMachine): + return False + + # Make sure they share the same name + if not (self.name == otherFSM.name) or \ + not (self.start_state_name == otherFSM.start_state_name): + return False + + # Ensure they have the same number of FSM-level actions + if len(self._actions) != len(otherFSM._actions): + return False + + # Test that we have all the same FSM level actions + for act in self._actions: + found = False + # For every action in the other FSM, try to match it with the + # current one. + for otherAct in otherFSM._actions: + if act == otherAct: + found = True + break + if found == False: + return False + + # Make sure we have the same number of states in both FSMs + if len(self._states) != len(otherFSM._states): + return False + + # For each state, try to find a corresponding state in the other FSM + for state_name in self._states.keys(): + state = self._states[state_name] + other_state = None + try: + # Attempt to use this key in the other FSM. If it's not present + # the dictionary will throw an exception and we'll know we have + # at least one different state in the other FSM + other_state = otherFSM._states[state_name] + except: + return False + # If two states with the same name exist, then we want to make sure + # they are also identical + if not state == other_state: + return False + + # If we made it here, then all the states in this FSM could be matched to an + # identical state in the other FSM. + return True diff --git a/src/tutorius/creator.py b/src/tutorius/creator.py new file mode 100644 index 0000000..c477056 --- /dev/null +++ b/src/tutorius/creator.py @@ -0,0 +1,733 @@ +""" +This package contains UI classes related to tutorial authoring. +This includes visual display of tools to edit and create tutorials from within +the activity itself. +""" +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Simon Poirier +# +# +# This program 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 1 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gtk.gdk +import gtk.glade +import gobject +from gettext import gettext as T + +import os +from sugar.graphics import icon +import copy + +from . import overlayer, gtkutils, actions, vault, properties, addon +from . import filters +from .services import ObjectStore +from .core import Tutorial, FiniteStateMachine, State +from . import viewer + +class Creator(object): + """ + Class acting as a bridge between the creator, serialization and core + classes. This contains most of the UI part of the editor. + """ + def __init__(self, activity, tutorial=None): + """ + Instanciate a tutorial creator for the activity. + + @param activity to bind the creator to + @param tutorial an existing tutorial to edit, or None to create one + """ + self._activity = activity + if not tutorial: + self._tutorial = FiniteStateMachine('Untitled') + self._state = State(name='INIT') + self._tutorial.add_state(self._state) + self._state_counter = 1 + else: + self._tutorial = tutorial + # TODO load existing tutorial; unused yet + + self._action_panel = None + self._current_filter = None + self._intro_mask = None + self._intro_handle = None + allocation = self._activity.get_allocation() + self._width = allocation.width + self._height = allocation.height + self._selected_widget = None + self._eventmenu = None + self.tuto = None + self._guid = None + + self._hlmask = overlayer.Rectangle(None, (1.0, 0.0, 0.0, 0.5)) + self._activity._overlayer.put(self._hlmask, 0, 0) + + dlg_width = 300 + dlg_height = 70 + sw = gtk.gdk.screen_width() + sh = gtk.gdk.screen_height() + + self._propedit = ToolBox(self._activity) + self._propedit.tree.signal_autoconnect({ + 'on_quit_clicked': self._cleanup_cb, + 'on_save_clicked': self.save, + 'on_action_activate': self._add_action_cb, + 'on_event_activate': self._add_event_cb, + }) + self._propedit.window.move( + gtk.gdk.screen_width()-self._propedit.window.get_allocation().width, + 100) + + + self._overview = viewer.Viewer(self._tutorial, self) + self._overview.win.set_transient_for(self._activity) + + self._overview.win.move(0, gtk.gdk.screen_height()- \ + self._overview.win.get_allocation().height) + + self._transitions = dict() + + def _update_next_state(self, state, event, next_state): + self._transitions[event] = next_state + + evts = state.get_event_filter_list() + state.clear_event_filters() + for evt, next_state in evts: + state.add_event_filter(evt, self._transitions[evt]) + + def delete_action(self, action): + """ + Removes the first instance of specified action from the tutorial. + + @param action: the action object to remove from the tutorial + @returns: True if successful, otherwise False. + """ + state = self._tutorial.get_state_by_name("INIT") + + while True: + state_actions = state.get_action_list() + for fsm_action in state_actions: + if fsm_action is action: + state.clear_actions() + if state is self._state: + fsm_action.exit_editmode() + state_actions.remove(fsm_action) + self.set_insertion_point(state.name) + for keep_action in state_actions: + state.add_action(keep_action) + return True + + ev_list = state.get_event_filter_list() + if ev_list: + state = self._tutorial.get_state_by_name(ev_list[0][1]) + continue + + return False + + def delete_state(self): + """ + Remove current state. + Limitation: The last state cannot be removed, as it doesn't have + any transitions to remove anyway. + + @returns: True if successful, otherwise False. + """ + if not self._state.get_event_filter_list(): + # last state cannot be removed + return False + + state = self._tutorial.get_state_by_name("INIT") + ev_list = state.get_event_filter_list() + if state is self._state: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + self.set_insertion_point(next_state.name) + self._tutorial.remove_state(state.name) + self._tutorial.remove_state(next_state.name) + next_state.name = "INIT" + self._tutorial.add_state(next_state) + return True + + # loop to repair links from deleted state + while ev_list: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + if next_state is self._state: + # the tutorial will flush the event filters. We'll need to + # clear and re-add them. + self._tutorial.remove_state(self._state.name) + state.clear_event_filters() + self._update_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1]) + for ev, next_state in ev_list: + state.add_event_filter(ev, next_state) + + self.set_insertion_point(ev_list[0][1]) + return True + + state = next_state + ev_list = state.get_event_filter_list() + return False + + def get_insertion_point(self): + return self._state.name + + def set_insertion_point(self, state_name): + for action in self._state.get_action_list(): + action.exit_editmode() + self._state = self._tutorial.get_state_by_name(state_name) + self._overview.win.queue_draw() + state_actions = self._state.get_action_list() + for action in state_actions: + action.enter_editmode() + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + + if state_actions: + self._propedit.action = state_actions[0] + else: + self._propedit.action = None + + + def _evfilt_cb(self, menuitem, event): + """ + This will get called once the user has selected a menu item from the + event filter popup menu. This should add the correct event filter + to the FSM and increment states. + """ + # undo actions so they don't persist through step editing + for action in self._state.get_action_list(): + action.exit_editmode() + self._hlmask.covered = None + self._propedit.action = None + self._activity.queue_draw() + + def _intro_cb(self, widget, evt): + """ + Callback for capture of widget events, when in introspect mode. + """ + if evt.type == gtk.gdk.BUTTON_PRESS: + # widget has focus, let's hilight it + win = gtk.gdk.display_get_default().get_window_at_pointer() + click_wdg = win[0].get_user_data() + if not click_wdg.is_ancestor(self._activity._overlayer): + # as popups are not (yet) supported, it would break + # badly if we were to play with a widget not in the + # hierarchy. + return + for hole in self._intro_mask.pass_thru: + self._intro_mask.mask(hole) + self._intro_mask.unmask(click_wdg) + self._selected_widget = gtkutils.raddr_lookup(click_wdg) + + if self._eventmenu: + self._eventmenu.destroy() + self._eventmenu = gtk.Menu() + menuitem = gtk.MenuItem(label=type(click_wdg).__name__) + menuitem.set_sensitive(False) + self._eventmenu.append(menuitem) + self._eventmenu.append(gtk.MenuItem()) + + for item in gobject.signal_list_names(click_wdg): + menuitem = gtk.MenuItem(label=item) + menuitem.connect("activate", self._evfilt_cb, item) + self._eventmenu.append(menuitem) + self._eventmenu.show_all() + self._eventmenu.popup(None, None, None, evt.button, evt.time) + self._activity.queue_draw() + + def _add_action_cb(self, widget, path): + """Callback for the action creation toolbar tool""" + action_type = self._propedit.actions_list[path][ToolBox.ICON_NAME] + action = addon.create(action_type) + action.enter_editmode() + self._state.add_action(action) + # FIXME: replace following with event catching + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + self._overview.win.queue_draw() + + def _add_event_cb(self, widget, path): + """Callback for the event creation toolbar tool""" + event_type = self._propedit.events_list[path][ToolBox.ICON_NAME] + event = addon.create(event_type) + addonname = type(event).__name__ + meta = addon.get_addon_meta(addonname) + for propname in meta['mandatory_props']: + prop = getattr(type(event), propname) + if isinstance(prop, properties.TUAMProperty): + selector = WidgetSelector(self._activity) + setattr(event, propname, selector.select()) + elif isinstance(prop, properties.TEventType): + try: + dlg = SignalInputDialog(self._activity, + text="Mandatory property", + field=propname, + addr=event.object_id) + setattr(event, propname, dlg.pop()) + except AttributeError: + pass + elif isinstance(prop, properties.TStringProperty): + dlg = TextInputDialog(self._activity, + text="Mandatory property", + field=propname) + setattr(event, propname, dlg.pop()) + else: + raise NotImplementedError() + + event_filters = self._state.get_event_filter_list() + if event_filters: + # linearize tutorial by inserting state + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + self._state.clear_event_filters() + for evt_filt, next_state in event_filters: + new_state.add_event_filter(evt_filt, next_state) + self._update_next_state(self._state, event, new_state.name) + next_state = new_state.name + # blocks are shifted, full redraw is necessary + self._overview.win.queue_draw() + else: + # append empty state only if edit inserting at end of linearized + # tutorial. + self._update_next_state(self._state, event, str(self._state_counter)) + next_state = str(self._state_counter) + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + + self._state.add_event_filter(event, next_state) + self._tutorial.add_state(new_state) + self._overview.win.queue_draw() + + self.set_insertion_point(new_state.name) + + def _action_refresh_cb(self, widget, evt, action): + """ + Callback for refreshing properties values and notifying the + property dialog of the new values. + """ + action.exit_editmode() + action.enter_editmode() + self._activity.queue_draw() + # TODO: replace following with event catching + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + self._propedit.action = action + + self._overview.win.queue_draw() + + def _cleanup_cb(self, *args): + """ + Quit editing and cleanup interface artifacts. + """ + # undo actions so they don't persist through step editing + for action in self._state.get_action_list(): + action.exit_editmode() + + dialog = gtk.MessageDialog( + parent=self._activity, + flags=gtk.DIALOG_MODAL, + type=gtk.MESSAGE_QUESTION, + buttons=gtk.BUTTONS_YES_NO, + message_format=T('Do you want to save before stopping edition?')) + do_save = dialog.run() + dialog.destroy() + if do_save == gtk.RESPONSE_YES: + self.save() + + # remove UI remains + self._hlmask.covered = None + self._activity._overlayer.remove(self._hlmask) + self._hlmask.destroy() + self._hlmask = None + self._propedit.destroy() + self._overview.destroy() + self._activity.queue_draw() + del self._activity._creator + + def save(self, widget=None): + if not self.tuto: + dlg = TextInputDialog(self._activity, + text=T("Enter a tutorial title."), + field=T("Title")) + tutorialName = "" + while not tutorialName: tutorialName = dlg.pop() + dlg.destroy() + + # prepare tutorial for serialization + self.tuto = Tutorial(tutorialName, self._tutorial) + bundle = vault.TutorialBundler(self._guid) + self._guid = bundle.Guid + bundle.write_metadata_file(self.tuto) + bundle.write_fsm(self._tutorial) + + + def launch(*args, **kwargs): + """ + Launch and attach a creator to the currently running activity. + """ + activity = ObjectStore().activity + if not hasattr(activity, "_creator"): + activity._creator = Creator(activity) + launch = staticmethod(launch) + +class ToolBox(object): + ICON_LABEL = 0 + ICON_IMAGE = 1 + ICON_NAME = 2 + ICON_TIP = 3 + def __init__(self, parent): + super(ToolBox, self).__init__() + self.__parent = parent + sugar_prefix = os.getenv("SUGAR_PREFIX",default="/usr") + glade_file = os.path.join(sugar_prefix, 'share', 'tutorius', + 'ui', 'creator.glade') + self.tree = gtk.glade.XML(glade_file) + self.window = self.tree.get_widget('mainwindow') + self._propbox = self.tree.get_widget('propbox') + + self.window.set_transient_for(parent) + + self._action = None + self.actions_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.actions_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + self.events_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.events_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + + for toolname in addon.list_addons(): + meta = addon.get_addon_meta(toolname) + iconfile = gtk.Image() + iconfile.set_from_file(icon.get_icon_file_name(meta['icon'])) + img = iconfile.get_pixbuf() + label = format_multiline(meta['display_name']) + + if meta['type'] == addon.TYPE_ACTION: + self.actions_list.append((label, img, toolname, meta['display_name'])) + else: + self.events_list.append((label, img, toolname, meta['display_name'])) + + iconview_action = self.tree.get_widget('iconview1') + iconview_action.set_model(self.actions_list) + iconview_action.set_text_column(self.ICON_LABEL) + iconview_action.set_pixbuf_column(self.ICON_IMAGE) + iconview_action.set_tooltip_column(self.ICON_TIP) + iconview_event = self.tree.get_widget('iconview2') + iconview_event.set_model(self.events_list) + iconview_event.set_text_column(self.ICON_LABEL) + iconview_event.set_pixbuf_column(self.ICON_IMAGE) + iconview_event.set_tooltip_column(self.ICON_TIP) + + self.window.show() + + def destroy(self): + """ clean and free the toolbox """ + self.window.destroy() + + def refresh_properties(self): + """Refresh property values from the selected action.""" + if self._action is None: + return + props = self._action._props.keys() + for propnum in xrange(len(props)): + row = self._propbox.get_children()[propnum] + propname = props[propnum] + prop = getattr(type(self._action), propname) + propval = getattr(self._action, propname) + if isinstance(prop, properties.TStringProperty): + propwdg = row.get_children()[1] + propwdg.get_buffer().set_text(propval) + elif isinstance(prop, properties.TUAMProperty): + propwdg = row.get_children()[1] + propwdg.set_label(propval) + elif isinstance(prop, properties.TIntProperty): + propwdg = row.get_children()[1] + propwdg.set_value(propval) + elif isinstance(prop, properties.TArrayProperty): + propwdg = row.get_children()[1] + for i in xrange(len(propval)): + entry = propwdg.get_children()[i] + entry.set_text(str(propval[i])) + else: + propwdg = row.get_children()[1] + propwdg.set_text(str(propval)) + + def set_action(self, action): + """Setter for the action property.""" + if self._action is action: + self.refresh_properties() + return + for old_prop in self._propbox.get_children(): + self._propbox.remove(old_prop) + + self._action = action + if action is None: + return + for propname in action._props.keys(): + row = gtk.HBox() + row.pack_start(gtk.Label(T(propname)), False, False, 10) + prop = getattr(type(action), propname) + propval = getattr(action, propname) + if isinstance(prop, properties.TStringProperty): + propwdg = gtk.TextView() + propwdg.get_buffer().set_text(propval) + propwdg.connect_after("focus-out-event", \ + self._str_prop_changed, action, propname) + elif isinstance(prop, properties.TUAMProperty): + propwdg = gtk.Button(propval) + propwdg.connect_after("clicked", \ + self._uam_prop_changed, action, propname) + elif isinstance(prop, properties.TIntProperty): + adjustment = gtk.Adjustment(value=propval, + lower=prop.lower_limit.limit, + upper=prop.upper_limit.limit, + step_incr=1) + propwdg = gtk.SpinButton(adjustment=adjustment) + propwdg.connect_after("focus-out-event", \ + self._int_prop_changed, action, prop) + elif isinstance(prop, properties.TArrayProperty): + propwdg = gtk.HBox() + for i in xrange(len(propval)): + entry = gtk.Entry() + propwdg.pack_start(entry) + entry.connect_after("focus-out-event", \ + self._list_prop_changed, action, propname, i) + else: + propwdg = gtk.Entry() + propwdg.set_text(str(propval)) + row.pack_end(propwdg) + self._propbox.pack_start(row, expand=False) + self._propbox.show_all() + self.refresh_properties() + + def get_action(self): + """Getter for the action property""" + return self._action + action = property(fset=set_action, fget=get_action, doc=\ + "Action to be edited through introspection.") + + def _list_prop_changed(self, widget, evt, action, propname, idx): + try: + #Save props as tuples so that they can be hashed + attr = list(getattr(action, propname)) + attr[idx] = int(widget.get_text()) + setattr(action, propname, tuple(attr)) + except ValueError: + widget.set_text(str(getattr(action, propname)[idx])) + self.__parent._creator._action_refresh_cb(None, None, action) + def _uam_prop_changed(self, widget, action, propname): + selector = WidgetSelector(self.__parent) + selection = selector.select() + setattr(action, propname, selection) + self.__parent._creator._action_refresh_cb(None, None, action) + def _str_prop_changed(self, widget, evt, action, propname): + buf = widget.get_buffer() + setattr(action, propname, buf.get_text(buf.get_start_iter(), buf.get_end_iter())) + self.__parent._creator._action_refresh_cb(None, None, action) + def _int_prop_changed(self, widget, evt, action, prop): + setattr(action, propname, widget.get_value_as_int()) + self.__parent._creator._action_refresh_cb(None, None, action) + + +class WidgetSelector(object): + """ + Allow selecting a widget from within a window without interrupting the + flow of the current call. + + The selector will run on the specified window until either a widget + is selected or abort() gets called. + """ + def __init__(self, window): + super(WidgetSelector, self).__init__() + self.window = window + self._intro_mask = None + self._intro_handle = None + self._select_handle = None + self._prelight = None + + def select(self): + """ + Starts selecting a widget, by grabbing control of the mouse and + highlighting hovered widgets until one is clicked. + @returns: a widget address or None + """ + if not self._intro_mask: + self._prelight = None + self._intro_mask = overlayer.Mask(catch_events=True) + self._select_handle = self._intro_mask.connect_after( + "button-press-event", self._end_introspect) + self._intro_handle = self._intro_mask.connect_after( + "motion-notify-event", self._intro_cb) + self.window._overlayer.put(self._intro_mask, 0, 0) + self.window._overlayer.queue_draw() + + while bool(self._intro_mask) and not gtk.main_iteration(): + pass + + return gtkutils.raddr_lookup(self._prelight) + + def _end_introspect(self, widget, evt): + if evt.type == gtk.gdk.BUTTON_PRESS and self._prelight: + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + # for some reason, gtk may not redraw after this unless told to. + self.window.queue_draw() + + def _intro_cb(self, widget, evt): + """ + Callback for capture of widget events, when in introspect mode. + """ + # widget has focus, let's hilight it + win = gtk.gdk.display_get_default().get_window_at_pointer() + if not win: + return + click_wdg = win[0].get_user_data() + if not click_wdg.is_ancestor(self.window._overlayer): + # as popups are not (yet) supported, it would break + # badly if we were to play with a widget not in the + # hierarchy. + return + for hole in self._intro_mask.pass_thru: + self._intro_mask.mask(hole) + self._intro_mask.unmask(click_wdg) + self._prelight = click_wdg + + self.window.queue_draw() + + def abort(self): + """ + Ends the selection. The control will return to the select() caller + with a return value of None, as selection was aborted. + """ + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + self._prelight = None + +class SignalInputDialog(gtk.MessageDialog): + def __init__(self, parent, text, field, addr): + """ + Create a gtk signal selection dialog. + + @param parent: the parent window this dialog should stay over. + @param text: the title of the dialog. + @param field: the field description of the dialog. + @param addr: the widget address from which to fetch signal list. + """ + gtk.MessageDialog.__init__(self, parent, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + gtk.MESSAGE_QUESTION, + gtk.BUTTONS_OK, + None) + self.set_markup(text) + self.model = gtk.ListStore(str) + widget = gtkutils.find_widget(parent, addr) + for signal_name in gobject.signal_list_names(widget): + self.model.append(row=(signal_name,)) + self.entry = gtk.ComboBox(self.model) + cell = gtk.CellRendererText() + self.entry.pack_start(cell) + self.entry.add_attribute(cell, 'text', 0) + hbox = gtk.HBox() + lbl = gtk.Label(field) + hbox.pack_start(lbl, False) + hbox.pack_end(self.entry) + self.vbox.pack_end(hbox, True, True) + self.show_all() + + def pop(self): + """ + Show the dialog. It will run in it's own loop and return control + to the caller when a signal has been selected. + + @returns: a signal name or None if no signal was selected + """ + self.run() + self.hide() + iter = self.entry.get_active_iter() + if iter: + text = self.model.get_value(iter, 0) + return text + return None + + def _dialog_done_cb(self, entry, response): + self.response(response) + +class TextInputDialog(gtk.MessageDialog): + def __init__(self, parent, text, field): + gtk.MessageDialog.__init__(self, parent, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + gtk.MESSAGE_QUESTION, + gtk.BUTTONS_OK, + None) + self.set_markup(text) + self.entry = gtk.Entry() + self.entry.connect("activate", self._dialog_done_cb, gtk.RESPONSE_OK) + hbox = gtk.HBox() + lbl = gtk.Label(field) + hbox.pack_start(lbl, False) + hbox.pack_end(self.entry) + self.vbox.pack_end(hbox, True, True) + self.show_all() + + def pop(self): + self.run() + self.hide() + text = self.entry.get_text() + return text + + def _dialog_done_cb(self, entry, response): + self.response(response) + +# The purpose of this function is to reformat text, as current IconView +# implentation does not insert carriage returns on long lines. +# To preserve layout, this call reformat text to fit in small space under an +# icon. +def format_multiline(text, length=10, lines=3, line_separator='\n'): + """ + Reformat a text to fit in a small space. + + @param length: maximum char per line + @param lines: maximum number of lines + """ + words = text.split(' ') + line = list() + return_val = [] + linelen = 0 + + for word in words: + t_len = linelen+len(word) + if t_len < length: + line.append(word) + linelen = t_len+1 # count space + else: + if len(return_val)+1 < lines: + return_val.append(' '.join(line)) + line = list() + linelen = 0 + line.append(word) + else: + return_val.append(' '.join(line+['...'])) + return line_separator.join(return_val) + + return_val.append(' '.join(line)) + return line_separator.join(return_val) + + +# vim:set ts=4 sts=4 sw=4 et: diff --git a/src/tutorius/dbustools.py b/src/tutorius/dbustools.py new file mode 100644 index 0000000..5d70d7b --- /dev/null +++ b/src/tutorius/dbustools.py @@ -0,0 +1,42 @@ +import logging +LOGGER = logging.getLogger("sugar.tutorius.dbustools") + +def save_args(callable, *xargs, **xkwargs): + def __call(*args, **kwargs): + kw = dict() + kw.update(kwargs) + kw.update(xkwargs) + return callable(*(xargs+args), **kw) + return __call + +def ignore(*args): + LOGGER.debug("Unhandled asynchronous dbus call response with arguments: %s", str(args)) + +def logError(error): + LOGGER.error("Unhandled asynchronous dbus call error: %s", error) + +def remote_call(callable, args, return_cb=None, error_cb=None, block=False): + reply_cb = return_cb or ignore + errhandler_cb = error_cb or logError + if block: + try: + ret_val = callable(*args) + LOGGER.debug("remote_call return arguments: %s", str(ret_val)) + except Exception, e: + #Use the specified error handler even for blocking calls + errhandler_cb(e) + return + + #Return value signature might be : + if ret_val is None: + #Nothing + return reply_cb() + elif type(ret_val) in (list, tuple): + #Several parameters + return reply_cb(*ret_val) + else: + #One parameter + return reply_cb(ret_val) + else: + callable(*args, reply_handler=reply_cb, error_handler=errhandler_cb) + diff --git a/src/tutorius/dialog.py b/src/tutorius/dialog.py new file mode 100644 index 0000000..be51a0e --- /dev/null +++ b/src/tutorius/dialog.py @@ -0,0 +1,59 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +The Dialog module provides means of interacting with the user +through the use of Dialogs. +""" +import gtk + +class TutoriusDialog(gtk.Dialog): + """ + TutoriusDialog is a simple wrapper around gtk.Dialog. + + It allows creating and showing a dialog and connecting the response and + button click events to callbacks. + """ + def __init__(self, label="Hint", button_clicked_cb=None, response_cb=None): + """ + Constructor. + + @param label text to be shown on the dialog + @param button_clicked_cb callback for the button click + @param response_cb callback for the dialog response + """ + gtk.Dialog.__init__(self) + + self._button = gtk.Button(label) + + self.add_action_widget(self._button, 1) + + if not button_clicked_cb == None: + self._button.connect("clicked", button_clicked_cb) + + self._button.show() + + if not response_cb == None: + self.connect("response", response_cb) + + self.set_decorated(False) + + def set_button_clicked_cb(self, funct): + """Setter for the button_clicked callback""" + self._button.connect("clicked", funct) + + def close_self(self, arg=None): + """Close the dialog""" + self.destroy() diff --git a/src/tutorius/editor.py b/src/tutorius/editor.py new file mode 100644 index 0000000..9d2effe --- /dev/null +++ b/src/tutorius/editor.py @@ -0,0 +1,318 @@ +# Copyright (C) 2009, Tutorius.org +# Greatly influenced by sugar/activity/namingalert.py +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" Tutorial Editor Module +""" + +import gtk +import gobject +#import hippo +#import gconf + +from gettext import gettext as _ + +from .gtkutils import register_signals_numbered, get_children + +class WidgetIdentifier(gtk.Window): + """ + Tool that allows identifying widgets. + + """ + __gtype_name__ = 'TutoriusWidgetIdentifier' + + def __init__(self, activity): + gtk.Window.__init__(self) + + self._activity = activity + self._handlers = {} + # dict of signals to register on the widgets. + # key : signal name + # value : initial checkbox status + signals = { + "focus":True, + "button-press-event":True, + "enter-notify-event":False, + "leave-notify-event":False, + "key-press-event":True, + "text-selected":True, + "clicked":True, + } + + self.set_decorated(False) + self.set_resizable(False) + self.set_modal(False) + + self.connect('realize', self.__realize_cb) + + self._expander = gtk.Expander(_("Widget Identifier")) + self._expander.set_expanded(True) + self.add(self._expander) + self._expander.connect("notify::expanded", self.__expander_cb) + + self._expander.show() + + nbk = gtk.Notebook() + self._expander.add(nbk) + nbk.show() + + ############################### + # Event log viewer page + ############################### + self.logview = gtk.TextView() + self.logview.set_editable(False) + self.logview.set_cursor_visible(False) + self.logview.set_wrap_mode(gtk.WRAP_NONE) + self._textbuffer = self.logview.get_buffer() + + swd = gtk.ScrolledWindow() + swd.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + swd.add(self.logview) + self.logview.show() + + nbk.append_page(swd, gtk.Label(_("Log"))) + swd.show() + + ############################### + # Filters page + ############################### + filters = gtk.Table( (len(signals)+1)/2, 2) + + xpos, ypos = 0, 0 + for key, active in signals.items(): + cbtn = gtk.CheckButton(label=key) + filters.attach(cbtn, xpos, xpos+1, ypos, ypos+1) + cbtn.show() + cbtn.set_active(active) + if active: + self._handlers[key] = register_signals_numbered( \ + self._activity, self._handle_events, events=(key,)) + else: + self._handlers[key] = [] + + cbtn.connect("toggled", self.__filter_toggle_cb, key) + + #Follow lines then columns + xpos, ypos = (xpos+1)%2, ypos+(xpos%2) + + nbk.append_page(filters, gtk.Label(_("Events"))) + filters.show() + + ############################### + # Explorer Page + ############################### + tree = gtk.TreeStore(gobject.TYPE_STRING, gobject.TYPE_STRING) + explorer = gtk.TreeView(tree) + + pathrendr = gtk.CellRendererText() + pathrendr.set_properties(background="#ffffff", foreground="#000000") + pathcol = gtk.TreeViewColumn(_("Path"), pathrendr, text=0, background=0, foreground=0) + explorer.append_column(pathcol) + + typerendr = gtk.CellRendererText() + typerendr.set_properties(background="#ffffff", foreground="#000000") + typecol = gtk.TreeViewColumn(_("Widget"), typerendr, text=1, background=1, foreground=1) + explorer.append_column(typecol) + + self.__populate_treestore( + tree, #tree + tree.append(None, ["0",self._activity.get_name()]), #parent + self._activity, #widget + "0" #path + ) + + explorer.set_expander_column(typecol) + + swd2 = gtk.ScrolledWindow() + swd2.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + swd2.add(explorer) + explorer.show() + nbk.append_page(swd2, gtk.Label(_("Explorer"))) + swd2.show() + + ############################### + # GObject Explorer Page + ############################### + tree2 = gtk.TreeStore(gobject.TYPE_STRING, gobject.TYPE_STRING) + explorer2 = gtk.TreeView(tree2) + + pathrendr2 = gtk.CellRendererText() + pathrendr2.set_properties(background="#ffffff", foreground="#000000") + pathcol2 = gtk.TreeViewColumn(_("Path"), pathrendr2, text=0, background=0, foreground=0) + explorer2.append_column(pathcol2) + + typerendr2 = gtk.CellRendererText() + typerendr2.set_properties(background="#ffffff", foreground="#000000") + typecol2 = gtk.TreeViewColumn(_("Widget"), typerendr2, text=1, background=1, foreground=1) + explorer2.append_column(typecol2) + + self.__populate_gobject_treestore( + tree2, #tree + tree2.append(None, ["activity",self._activity.get_name()]), #parent + self._activity, #widget + "activity" #path + ) + + explorer2.set_expander_column(typecol2) + + swd3 = gtk.ScrolledWindow() + swd3.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + swd3.add(explorer2) + explorer2.show() + nbk.append_page(swd3, gtk.Label(_("GObject Explorer"))) + swd3.show() + + def __populate_treestore(self, tree, parent, widget, path): + """Populates the treestore with the widget's children recursively + @param tree gtk.TreeStore to populate + @param parent gtk.TreeIter to append to + @param widget gtk.Widget to check for children + @param path treeish of the widget + """ + #DEBUG: show parameters in log window gehehe + #self._handle_events((path,str(type(widget)))) + children = get_children(widget) + for i in xrange(len(children)): + childpath = ".".join([path, str(i)]) + child = children[i] + self.__populate_treestore( + tree, #tree + tree.append(parent, [childpath, child.get_name()]), #parent + child, #widget + childpath #path + ) + + + def __populate_gobject_treestore(self, tree, parent, widget, path, listed=None): + """Populates the treestore with the widget's children recursively + @param tree gtk.TreeStore to populate + @param parent gtk.TreeIter to append to + @param widget gtk.Widget to check for children + @param path treeish of the widget + """ + listed = listed or [] + if widget in listed: + return + listed.append(widget) + #DEBUG: show parameters in log window gehehe + #self._handle_events((path,str(type(widget)))) + #Add a child node + children = tree.append(parent, ["","children"]) + for i in dir(widget): + #Add if a gobject + try: + child = getattr(widget, i) + except: + continue + if isinstance(child,gobject.GObject): + childpath = ".".join([path, i]) + child = getattr(widget, i) + self.__populate_gobject_treestore( + tree, #tree + tree.append(children, [childpath, i]), #parent + child, #widget + path + "." + i, #path, + listed + ) + widgets = tree.append(parent, ["","widgets"]) + wchildren = get_children(widget) + for i in xrange(len(wchildren)): + childpath = ".".join([path, str(i)]) + child = wchildren[i] + self.__populate_gobject_treestore( + tree, #tree + tree.append(widgets, [childpath, (hasattr(child,"get_name") and child.get_name()) or i]), #parent + child, #widget + childpath, #path, + listed + ) + + #Add signals and attributes nodes + signals = tree.append(parent, ["","signals"]) + for signame in gobject.signal_list_names(widget): + tree.append(signals, ["",signame]) + + attributes = tree.append(parent, ["","properties"]) + for prop in gobject.list_properties(widget): + tree.append(attributes, ["",prop]) + + def __filter_toggle_cb(self, btn, eventname): + """Callback for signal name checkbuttons' toggling""" + #Disconnect existing handlers on key + self.__disconnect_handlers(eventname) + if btn.get_active(): + #if checked, reconnect + self._handlers[eventname] = register_signals_numbered( \ + self._activity, self._handle_events, events=(eventname,)) + + + def __expander_cb(self, *args): + """Callback for the window expander toggling""" + if self._expander.get_expanded(): + self.__move_expanded() + else: + self.__move_collapsed() + + def __move_expanded(self): + """Move the window to it's expanded position""" + width = 500 + height = 300 + swidth = gtk.gdk.screen_width() + sheight = gtk.gdk.screen_height() + + self.set_size_request(width, height) + self.move((swidth-width)/2, sheight-height) + + def __move_collapsed(self): + """Move the window to it's collapsed position""" + width = 150 + height = 40 + swidth = gtk.gdk.screen_width() + sheight = gtk.gdk.screen_height() + + self.set_size_request(width, height) + self.move((swidth-width)/2, sheight-height) + + def __realize_cb(self, widget): + """Callback for realize""" + self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG) + self.window.set_accept_focus(True) + self.__move_expanded() + + def _disconnect_handlers(self): + """ Disconnect all event handlers """ + for key in self._handlers: + self.__disconnect_handlers(key) + + def __disconnect_handlers(self, key): + """ Disconnect event handlers associated to signal name "key" """ + if self._handlers.has_key(key): + for widget, handlerid in self._handlers[key]: + widget.handler_disconnect(handlerid) + del self._handlers[key] + + def _handle_events(self, *args): + """ Event handler for subscribed widget events. + Accepts variable length argument list. Last must be + a two-tuple containing (event name, widget name) """ + sig, name = args[-1] + text = "\r\n".join( + (["%s event received from %s" % (sig, name)] + + self._textbuffer.get_text(*(self._textbuffer.get_bounds()) + ).split("\r\n"))[:80] + ) + self._textbuffer.set_text(text) + + diff --git a/src/tutorius/engine.py b/src/tutorius/engine.py new file mode 100644 index 0000000..e77a018 --- /dev/null +++ b/src/tutorius/engine.py @@ -0,0 +1,46 @@ +import logging +import dbus.mainloop.glib +from jarabe.model import shell +from sugar.bundle.activitybundle import ActivityBundle + +from .vault import Vault + +class Engine: + """ + Driver for the execution of tutorials + """ + + def __init__(self): + # FIXME Probe management should be in the probe manager + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + #FIXME shell.get_model() will only be useful in the shell process + self._shell = shell.get_model() + self._tutorial = None + + def launch(self, tutorialID): + """ Launch a tutorial + @param tutorialID unique tutorial identifier used to retrieve it from the disk + """ + if self._tutorial: + self._tutorial.detach() + self._tutorial = None + + #Get the active activity from the shell + activity = self._shell.get_active_activity() + self._tutorial = Vault.loadTutorial(tutorialID) + + #TProbes automatically use the bundle id, available from the ActivityBundle + bundle = ActivityBundle(activity.get_bundle_path()) + self._tutorial.attach(bundle.get_bundle_id()) + + def stop(self): + """ Stop the current tutorial + """ + self._tutorial.detach() + self._tutorial = None + + def pause(self): + """ Interrupt the current tutorial and save its state in the journal + """ + raise NotImplementedError("Unable to store tutorial state") + diff --git a/src/tutorius/filters.py b/src/tutorius/filters.py new file mode 100644 index 0000000..38cf86b --- /dev/null +++ b/src/tutorius/filters.py @@ -0,0 +1,74 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +logger = logging.getLogger("filters") + +from . import properties + + +class EventFilter(properties.TPropContainer): + """ + Base class for an event filter + """ + + def __init__(self): + """ + Constructor. + """ + super(EventFilter, self).__init__() + self._callback = None + + def install_handlers(self, callback, **kwargs): + """ + install_handlers is called for eventfilters to setup all + necessary event handlers to be able to catch the desired + event. + + @param callback the callback function that will be called + with the event filter as an argument when the event is catched + and validated. + @param **kwargs unused by this handler for now, allows subclasses + to receive information about the context when installing + + Subclasses must call this super method to setup the callback if they + feel like cooperating + """ + self._callback = callback + + def remove_handlers(self): + """ + remove_handlers is called when a state is done so that all + event filters can cleanup any handlers they have installed + + This function will also clear the callback function so that any + leftover handler that is triggered will not be able to change the + application state. + + subclasses must call this super method to cleanup the callback if they + collaborate and use this classe's do_callback() + """ + self._callback = None + + def do_callback(self, *args, **kwargs): + """ + Default callback function that calls the event filter callback + with the event filter as only argument. + """ + if self._callback: + self._callback(self) + diff --git a/src/tutorius/gtkutils.py b/src/tutorius/gtkutils.py new file mode 100644 index 0000000..1a9cb0f --- /dev/null +++ b/src/tutorius/gtkutils.py @@ -0,0 +1,203 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Utility classes and functions that are gtk related +""" +import gtk + +def raddr_lookup(widget): + name = [] + child = widget + parent = widget.parent + while parent: + name.append(str(parent.get_children().index(child))) + child = parent + parent = child.parent + + name.append("0") # root object itself + name.reverse() + return ".".join(name) + + +def find_widget(base, target_fqdn): + """Find a widget by digging into a parent widget's children tree + @param base the parent widget + @param target_fqdn fqdn-style target object name + + @return widget found + + The object should normally be the activity widget, as it is the root + widget for activities. The target_fqdn is a dot separated list of + indexes used in widget.get_children and should start with a 0 which is + the base widget itself, + + Example Usage: + find_widget(activity,"0.0.0.1.0.0.2") + """ + path = target_fqdn.split(".") + #We select the first object and pop the first zero + obj = base + path.pop(0) + + while len(path) > 0: + try: + obj = get_children(obj)[int(path.pop(0))] + except: + break + + return obj + +EVENTS = ( + "focus", + "button-press-event", + "enter-notify-event", + "leave-notify-event", + "key-press-event", + "text-selected", + "clicked", +) + +IGNORED_WIDGETS = ( + "GtkVBox", + "GtkHBox", + "GtkAlignment", + "GtkNotebook", + "GtkButton", + "GtkToolItem", + "GtkToolbar", +) + +def register_signals_numbered(target, handler, prefix="0", max_depth=None, events=None): + """ + Recursive function to register event handlers on an target + and it's children. The event handler is called with an extra + argument which is a two-tuple containing the signal name and + the FQDN-style name of the target that triggered the event. + + This function registers all of the events listed in + EVENTS + + Example arg tuple added: + ("focus", "1.1.2") + Side effects: + -Handlers connected on the various targets + + @param target the target to recurse on + @param handler the handler function to connect + @param prefix name prepended to the target name to form a chain + @param max_depth maximum recursion depth, None for infinity + + @returns list of (object, handler_id) + """ + ret = [] + evts = events or EVENTS + #Gtk Containers have a get_children() function + children = get_children(target) + for i in range(len(children)): + child = children[i] + if max_depth is None or max_depth > 0: + #Recurse with a prefix on all children + pre = ".".join( \ + [p for p in (prefix, str(i)) if not p is None] + ) + if max_depth is None: + dep = None + else: + dep = max_depth - 1 + ret+=register_signals_numbered(child, handler, pre, dep, evts) + #register events on the target if a widget XXX necessary to check this? + if isinstance(target, gtk.Widget): + for sig in evts: + try: + ret.append( \ + (target, target.connect(sig, handler, (sig, prefix) ))\ + ) + except TypeError: + pass + + return ret + +def register_signals(target, handler, prefix=None, max_depth=None, events=None): + """ + Recursive function to register event handlers on an target + and it's children. The event handler is called with an extra + argument which is a two-tuple containing the signal name and + the FQDN-style name of the target that triggered the event. + + This function registers all of the events listed in + EVENTS and omits widgets with a name matching + IGNORED_WIDGETS from the name hierarchy. + + Example arg tuple added: + ("focus", "Activity.Toolbox.Bold") + Side effects: + -Handlers connected on the various targets + + @param target the target to recurse on + @param handler the handler function to connect + @param prefix name prepended to the target name to form a chain + @param max_depth maximum recursion depth, None for infinity + + @returns list of (object, handler_id) + """ + ret = [] + evts = events or EVENTS + #Gtk Containers have a get_children() function + for child in get_children(target): + if max_depth is None or max_depth > 0: + #Recurse with a prefix on all children + pre = ".".join( \ + [p for p in (prefix, target.get_name()) \ + if not (p is None or p in IGNORED_WIDGETS)] \ + ) + if max_depth is None: + dep = None + else: + dep = max_depth - 1 + ret += register_signals(child, handler, pre, dep, evts) + name = ".".join( \ + [p for p in (prefix, target.get_name()) \ + if not (p is None or p in IGNORED_WIDGETS)] \ + ) + #register events on the target if a widget XXX necessary to check this? + if isinstance(target, gtk.Widget): + for sig in evts: + try: + ret.append( \ + (target, target.connect(sig, handler, (sig, name) )) \ + ) + except TypeError: + pass + + return ret + +def get_children(widget): + """Lists widget's children""" + #widgets with multiple children + try: + return widget.get_children() + except (AttributeError,TypeError): + pass + + #widgets with a single child + try: + return [widget.get_child(),] + except (AttributeError,TypeError): + pass + + #otherwise return empty list + return [] diff --git a/src/tutorius/linear_creator.py b/src/tutorius/linear_creator.py new file mode 100644 index 0000000..f664c49 --- /dev/null +++ b/src/tutorius/linear_creator.py @@ -0,0 +1,94 @@ +# Copyright (C) 2009, Tutorius.org +# Greatly influenced by sugar/activity/namingalert.py +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from copy import deepcopy + +from .core import * +from .actions import * +from .filters import * + +class LinearCreator(object): + """ + This class is used to create a FSM from a linear sequence of orders. The + orders themselves are meant to be either an action or a transition. + """ + + def __init__(self): + self.fsm = FiniteStateMachine("Sample Tutorial") + self.current_actions = [] + self.nb_state = 0 + self.state_name = "INIT" + + def set_name(self, name): + """ + Sets the name of the generated FSM. + """ + self.fsm.name = name + + def action(self, action): + """ + Adds an action to execute in the current state. + """ + self.current_actions.append(action) + + def event(self, event_filter): + """ + Adds a transition to another state. When executing this, all the actions + previously called will be bundled in a single state, with the exit + condition of this state being the transition just added. + + Whatever the name of the next state you inserted in the event, it will + be replaced to point to the next event in the line. + """ + if len(self.current_actions) != 0: + # Set the next state name - there is no way the caller should have + # to deal with that. + next_state_name = "State %d" % (self.nb_state+1) + state = State(self.state_name, action_list=self.current_actions, + event_filter_list=[(event_filter, next_state_name),]) + self.state_name = next_state_name + + self.nb_state += 1 + self.fsm.add_state(state) + + # Clear the actions from the list + self.current_actions = [] + + def generate_fsm(self): + """ + Returns a finite state machine corresponding to the sequence of calls + that were made from this point on. + """ + # Copy the whole FSM that was generated yet + new_fsm = deepcopy(self.fsm) + + # Generate the final state + state = None + if len(self.current_actions) != 0: + state = State("State" + str(self.nb_state), action_list=self.current_actions) + # Don't increment the nb_state here - we would break the linearity + # because we might generate more stuff with this creator later. + # Since we rely on linearity for continuity when generating the + # next state's name on an event filter, we cannot increment here. + else: + state = State("State" + str(self.nb_state)) + + # Insert the state in the copy of the FSM + new_fsm.add_state(state) + + return new_fsm + diff --git a/src/tutorius/overlayer.py b/src/tutorius/overlayer.py new file mode 100644 index 0000000..b967739 --- /dev/null +++ b/src/tutorius/overlayer.py @@ -0,0 +1,503 @@ +""" +This module manages drawing of overlayed widgets. The class responsible for +drawing management (Overlayer) and basic overlayable widgets are defined here. +""" +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gobject +import gtk +import cairo +import pangocairo +from math import pi + +from sugar import profile + +# for easy profile access from cairo +color = profile.get_color().get_stroke_color() +xo_line_color = (int(color[1:3], 16)/255.0, + int(color[3:5], 16)/255.0, + int(color[5:7], 16)/255.0) +color = profile.get_color().get_fill_color() +xo_fill_color = (int(color[1:3], 16)/255.0, + int(color[3:5], 16)/255.0, + int(color[5:7], 16)/255.0) +del color + +# This is the CanvasDrawable protocol. Any widget wishing to be drawn on the +# overlay must implement it. See TextBubble for a sample implementation. +#class CanvasDrawable(object): +# """Defines the CanvasDrawable protocol""" +# no_expose = None +# def draw_with_context(self, context): +# """ +# Draws the cairo widget with the passed cairo context. +# This will be called if the widget is child of an overlayer. +# """ +# pass + +class Overlayer(gtk.Layout): + """ + This guy manages drawing of overlayed widgets. Those can be standard GTK + widgets or special "cairoDrawable" widgets which support the defined + interface (see the put method). + + @param overlayed widget to be overlayed. Will be resized to full size. + """ + def __init__(self, overlayed=None): + super(Overlayer, self).__init__() + + self._overlayed = overlayed + if overlayed: + self.put(overlayed, 0, 0) + + self.__realizer = self.connect_after("realize", self.__init_realized) + self.connect("size-allocate", self.__size_allocate) + self.show() + + self.__render_handle = None + + def put(self, child, x, y): + """ + Adds a child widget to be overlayed. This can be, overlay widgets or + normal GTK widgets (though normal widgets will alwas appear under + cairo widgets due to the rendering chain). + + @param child the child to add + @param x the horizontal coordinate for positionning + @param y the vertical coordinate for positionning + """ + if hasattr(child, "draw_with_context"): + # if the widget has the CanvasDrawable protocol, use it. + child.no_expose = True + super(Overlayer, self).put(child, x, y) + + # be sure to redraw or the overlay may not show + self.queue_draw() + + + def __init_realized(self, widget): + """ + Initializer to set once widget is realized. + Since an expose event is signaled only to realized widgets, we set this + callback for the first expose run. It should also be called after + beign reparented to ensure the window used for drawing is set up. + """ + assert hasattr(self.window, "set_composited"), \ + "compositing not supported or widget not realized." + self.disconnect(self.__realizer) + del self.__realizer + + self.parent.set_app_paintable(True) + + # the parent is composited, so we can access gtk's rendered buffer + # and overlay over. If we don't composite, we won't be able to read + # pixels and background will be black. + self.window.set_composited(True) + self.__render_handle = self.parent.connect_after("expose-event", \ + self.__expose_overlay) + + def __expose_overlay(self, widget, event): + """expose event handler to draw the thing.""" + #get our child (in this case, the event box) + child = widget.get_child() + + #create a cairo context to draw to the window + ctx = widget.window.cairo_create() + + #the source data is the (composited) event box + ctx.set_source_pixmap(child.window, + child.allocation.x, + child.allocation.y) + + #draw no more than our expose event intersects our child + region = gtk.gdk.region_rectangle(child.allocation) + rect = gtk.gdk.region_rectangle(event.area) + region.intersect(rect) + ctx.region (region) + ctx.clip() + + ctx.set_operator(cairo.OPERATOR_OVER) + # has to be blended and a 1.0 alpha would not make it blend + ctx.paint_with_alpha(0.99) + + #draw overlay + for drawn_child in self.get_children()[1:]: + if hasattr(drawn_child, "draw_with_context"): + drawn_child.draw_with_context(ctx) + + def __size_allocate(self, widget, allocation): + """ + Set size allocation (actual gtk widget size) and propagate it to + overlayed child + """ + self.allocation = allocation + # One may wonder why using size_request instead of size_allocate; + # Since widget is laid out in a Layout box, the Layout will honor the + # requested size. Using size_allocate could make a nasty nested loop in + # some cases. + self._overlayed.set_size_request(allocation.width, allocation.height) + + +class TextBubble(gtk.Widget): + """ + A CanvasDrawableWidget drawing a round textbox and a tail pointing + to a specified widget. + """ + def __init__(self, text, speaker=None, tailpos=(0,0)): + """ + Creates a new cairo rendered text bubble. + + @param text the text to render in the bubble + @param speaker the widget to compute the tail position from + @param tailpos (optional) position relative to the bubble to use as + the tail position, if no speaker + """ + gtk.Widget.__init__(self) + + # FIXME: ensure previous call does not interfere with widget stacking, + # as using a gtk.Layout and stacking widgets may reveal a screwed up + # order with the cairo widget on top. + self.__label = None + + self.label = text + self.speaker = speaker + self.tailpos = tailpos + self.line_width = 5 + self.padding = 20 + + self._no_expose = False + self.__exposer = None + + def draw_with_context(self, context): + """ + Draw using the passed cairo context instead of creating a new cairo + context. This eases blending between multiple cairo-rendered + widgets. + """ + context.translate(self.allocation.x, self.allocation.y) + width = self.allocation.width + height = self.allocation.height + xradius = width/2 + yradius = height/2 + width -= self.line_width + height -= self.line_width + # + # TODO fetch speaker coordinates + + # draw bubble tail if present + if self.tailpos != (0,0): + context.move_to(xradius-width/4, yradius) + context.line_to(self.tailpos[0], self.tailpos[1]) + context.line_to(xradius+width/4, yradius) + context.set_line_width(self.line_width) + context.set_source_rgb(*xo_line_color) + context.stroke_preserve() + + # bubble border + context.move_to(width-self.padding, 0.0) + context.line_to(self.padding, 0.0) + context.arc_negative(self.padding, self.padding, self.padding, + 3*pi/2, pi) + context.line_to(0.0, height-self.padding) + context.arc_negative(self.padding, height-self.padding, self.padding, + pi, pi/2) + context.line_to(width-self.padding, height) + context.arc_negative(width-self.padding, height-self.padding, + self.padding, pi/2, 0) + context.line_to(width, self.padding) + context.arc_negative(width-self.padding, self.padding, self.padding, + 0.0, -pi/2) + context.set_line_width(self.line_width) + context.set_source_rgb(*xo_line_color) + context.stroke_preserve() + context.set_source_rgb(*xo_fill_color) + context.fill() + + # bubble painting. Redrawing the inside after the tail will combine + if self.tailpos != (0,0): + context.move_to(xradius-width/4, yradius) + context.line_to(self.tailpos[0], self.tailpos[1]) + context.line_to(xradius+width/4, yradius) + context.set_line_width(self.line_width) + context.set_source_rgb(*xo_fill_color) + context.fill() + + context.set_source_rgb(1.0, 1.0, 1.0) + pangoctx = pangocairo.CairoContext(context) + self._text_layout.set_markup(self.__label) + text_size = self._text_layout.get_pixel_size() + pangoctx.move_to( + int((self.allocation.width-text_size[0])/2), + int((self.allocation.height-text_size[1])/2)) + pangoctx.show_layout(self._text_layout) + + # work done. Be kind to next cairo widgets and reset matrix. + context.identity_matrix() + + def do_realize(self): + """ Setup gdk window creation. """ + self.set_flags(gtk.REALIZED | gtk.NO_WINDOW) + self.window = self.get_parent_window() + if not self._no_expose: + self.__exposer = self.connect_after("expose-event", \ + self.__on_expose) + + def __on_expose(self, widget, event): + """Redraw event callback.""" + ctx = self.window.cairo_create() + + self.draw_with_context(ctx) + + return True + + def _set_label(self, value): + """Sets the label and flags the widget to be redrawn.""" + self.__label = "%s"%value + if not self.parent: + return + ctx = self.parent.window.cairo_create() + pangoctx = pangocairo.CairoContext(ctx) + self._text_layout = pangoctx.create_layout() + self._text_layout.set_markup(value) + del pangoctx, ctx#, surf + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the widget.""" + ctx = self.parent.window.cairo_create() + pangoctx = pangocairo.CairoContext(ctx) + self._text_layout = pangoctx.create_layout() + self._text_layout.set_markup(self.__label) + + width, height = self._text_layout.get_pixel_size() + requisition.width = int(width+2*self.padding) + requisition.height = int(height+2*self.padding) + + def do_size_allocate(self, allocation): + """Save zone allocated to the widget.""" + self.allocation = allocation + + def _get_label(self): + """Getter method for the label property""" + return self.__label[3:-4] + + def _set_no_expose(self, value): + """setter for no_expose property""" + self._no_expose = value + if not (self.flags() and gtk.REALIZED): + return + + if self.__exposer and value: + self.parent.disconnect(self.__exposer) + self.__exposer = None + elif (not self.__exposer) and (not value): + self.__exposer = self.parent.connect_after("expose-event", + self.__on_expose) + + def _get_no_expose(self): + """getter for no_expose property""" + return self._no_expose + + no_expose = property(fset=_set_no_expose, fget=_get_no_expose, + doc="Whether the widget should handle exposition events or not.") + + label = property(fget=_get_label, fset=_set_label, + doc="Text label which is to be painted on the top of the widget") + +gobject.type_register(TextBubble) + +class Rectangle(gtk.Widget): + """ + A CanvasDrawableWidget drawing a rectangle over a specified widget. + """ + def __init__(self, widget, color): + """ + Creates a new Rectangle + + @param widget the widget to cover + @param color the color of the rectangle, as a 4-tuple RGBA + """ + gtk.Widget.__init__(self) + + self.covered = widget + self.color = color + + self.__exposer = self.connect("expose-event", self.__on_expose) + + def draw_with_context(self, context): + """ + Draw using the passed cairo context instead of creating a new cairo + context. This eases blending between multiple cairo-rendered + widgets. + """ + if self.covered is None: + # nothing to hide, no coordinates, no drawing + return + mask_alloc = self.covered.get_allocation() + x, y = self.covered.translate_coordinates(self.parent, 0, 0) + + context.rectangle(x, y, mask_alloc.width, mask_alloc.height) + context.set_source_rgba(*self.color) + context.fill() + + def do_realize(self): + """ Setup gdk window creation. """ + self.set_flags(gtk.REALIZED | gtk.NO_WINDOW) + + self.window = self.get_parent_window() + if not isinstance(self.parent, Overlayer): + assert False, "%s should not realize" % type(self).__name__ + print "Danger, Will Robinson! Rectangle parent is not Overlayer" + + def __on_expose(self, widget, event): + """Redraw event callback.""" + assert False, "%s wasn't meant to be exposed by gtk" % \ + type(self).__name__ + ctx = self.window.cairo_create() + + self.draw_with_context(ctx) + + return True + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the masked widget.""" + # This is a bit pointless, as this will always ignore allocation and + # be rendered directly on overlay, but for sanity, let's put some values + # in there. + if not self.covered: + requisition.width = 0 + requisition.height = 0 + return + + masked_alloc = self.covered.get_allocation() + requisition.width = masked_alloc.width + requisition.height = masked_alloc.height + + def do_size_allocate(self, allocation): + """Save zone allocated to the widget.""" + self.allocation = allocation + + def _set_no_expose(self, value): + """setter for no_expose property""" + if self.__exposer and value: + self.disconnect(self.__exposer) + self.__exposer = None + elif (not self.__exposer) and (not value): + self.__exposer = self.connect("expose-event", self.__on_expose) + + def _get_no_expose(self): + """getter for no_expose property""" + return not self.__exposer + + no_expose = property(fset=_set_no_expose, fget=_get_no_expose, + doc="Whether the widget should handle exposition events or not.") +gobject.type_register(Rectangle) + +class Mask(gtk.EventBox): + """ + A CanvasDrawableWidget drawing a rectangle over a specified widget. + """ + def __init__(self, catch_events=False, pass_thru=()): + """ + Creates a new Rectangle + + @param catch_events whether the Mask should catch events + @param pass_thru the widgets that "punch holes" through this Mask. + Events will pass through to those widgets. + """ + gtk.EventBox.__init__(self) + self.no_expose = True # ignored + self._catch_events = False + self.catch_events = catch_events + self.pass_thru = list(pass_thru) + + def __del__(self): + for widget in self.pass_thru: + widget.drag_unhighlight() + + def mask(self, widget): + """ + Remove the widget from the unmasked list. + @param widget a widget to remask + """ + assert widget in self.pass_thru, \ + "trying to mask already masked widget" + self.pass_thru.remove(widget) + widget.drag_unhighlight() + + def unmask(self, widget): + """ + Add to the unmasked list the widget passed. + A hole will be punched through the mask at that widget's position. + @param widget a widget to unmask + """ + if widget not in self.pass_thru: + widget.drag_highlight() + self.pass_thru.append(widget) + + + def set_catch_events(self, do_catch): + """Sets whether the mask catches events of widgets under it""" + if bool(self._catch_events) ^ bool(do_catch): + if do_catch: + self._catch_events = True + self.grab_add() + else: + self.grab_remove() + self._catch_events = False + + def get_catch_events(self): + """Gets whether the mask catches events of widgets under it""" + return bool(self._catch_handle) + + catch_events = property(fset=set_catch_events, fget=get_catch_events) + + def draw_with_context(self, context): + """ + Draw using the passed cairo context instead of creating a new cairo + context. This eases blending between multiple cairo-rendered + widgets. + """ + # Fill parent container + mask_alloc = self.parent.get_allocation() + oldrule = context.get_fill_rule() + context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) + x, y = self.translate_coordinates(self.parent, 0, 0) + + context.rectangle(x, y, mask_alloc.width, mask_alloc.height) + for hole in self.pass_thru: + alloc = hole.get_allocation() + x, y = hole.translate_coordinates(self.parent, 0, 0) + context.rectangle(x, y, alloc.width, alloc.height) + context.set_source_rgba(0, 0, 0, 0.7) + context.fill() + context.set_fill_rule(oldrule) + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the masked widget.""" + # This is required for the event box to span across all the parent. + alloc = self.parent.get_allocation() + requisition.width = alloc.width + requisition.height = alloc.height + + def do_size_allocate(self, allocation): + """Save zone allocated to the widget.""" + self.allocation = allocation + +gobject.type_register(Mask) + + +# vim:set ts=4 sts=4 sw=4 et: diff --git a/src/tutorius/properties.py b/src/tutorius/properties.py new file mode 100644 index 0000000..a675ba9 --- /dev/null +++ b/src/tutorius/properties.py @@ -0,0 +1,363 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module contains properties class that can be included in other types. +TutoriusProperties have the same behaviour as python properties (assuming you +also use the TPropContainer), with the added benefit of having builtin dialog +prompts and constraint validation. +""" +from copy import copy + +from .constraints import Constraint, \ + UpperLimitConstraint, LowerLimitConstraint, \ + MaxSizeConstraint, MinSizeConstraint, \ + ColorConstraint, FileConstraint, BooleanConstraint, EnumConstraint + +class TPropContainer(object): + """ + A class containing properties. This does the attribute wrapping between + the container instance and the property value. As properties are on the + containing classes, they allow static introspection of those types + at the cost of needing a mapping between container instances, and + property values. This is what TPropContainer does. + """ + def __init__(self): + """ + Prepares the instance for property value storage. This is done at + object initialization, thus allowing initial mapping of properties + declared on the class. Properties won't work correctly without + this call. + """ + # create property value storage + object.__setattr__(self, "_props", {}) + for attr_name in dir(type(self)): + propinstance = object.__getattribute__(self, attr_name) + if isinstance(propinstance, TutoriusProperty): + # only care about TutoriusProperty instances + propinstance.tname = attr_name + self._props[attr_name] = propinstance.validate( + copy(propinstance.default)) + + def __getattribute__(self, name): + """ + Process the 'fake' read of properties in the appropriate instance + container. Pass 'real' attributes as usual. + """ + try: + props = object.__getattribute__(self, "_props") + except AttributeError: + # necessary for deepcopy as order of init can't be guaranteed + object.__setattr__(self, "_props", {}) + props = object.__getattribute__(self, "_props") + + try: + # try gettin value from property storage + # if it's not in the map, it's not a property or its default wasn't + # set at initialization. + return props[name] + except KeyError: + return object.__getattribute__(self, name) + + def __setattr__(self, name, value): + """ + Process the 'fake' write of properties in the appropriate instance + container. Pass 'real' attributes as usual. + + @param name the name of the property + @param value the value to assign to name + @return the setted value + """ + props = object.__getattribute__(self, "_props") + try: + # We attempt to get the property object with __getattribute__ + # to work through inheritance and benefit of the MRO. + return props.__setitem__(name, + object.__getattribute__(self, name).validate(value)) + except AttributeError: + return object.__setattr__(self, name, value) + + def get_properties(self): + """ + Return the list of property names. + """ + return object.__getattribute__(self, "_props").keys() + + # Providing the hash methods necessary to use TPropContainers + # in a dictionary, according to their properties + def __hash__(self): + #Return a hash of properties (key, value) sorted by key + #We need to transform the list of property key, value lists into + # a tuple of key, value tuples + return hash(tuple(map(tuple,sorted(self._props.items(), cmp=lambda x, y: cmp(x[0], y[0]))))) + + def __eq__(self, e2): + return isinstance(e2, type(self)) and self._props == e2._props + + # Adding methods for pickling and unpickling an object with + # properties + def __getstate__(self): + return self._props.copy() + + def __setstate__(self, dict): + self._props.update(dict) + +class TutoriusProperty(object): + """ + The base class for all actions' properties. The interface is the following : + + value : the value of the property + + type : the type of the property + + get_contraints() : the constraints inserted on this property. They define + what is acceptable or not as values. + """ + def __init__(self): + super(TutoriusProperty, self).__init__() + self.type = None + self._constraints = None + self.default = None + + def get_constraints(self): + """ + Returns the list of constraints associated to this property. + """ + if self._constraints is None: + self._constraints = [] + for i in dir(self): + typ = getattr(self, i) + if isinstance(typ, Constraint): + self._constraints.append(i) + return self._constraints + + def validate(self, value): + """ + Validates the value of the property. If the value does not respect + the constraints on the property, this method will raise an exception. + + The exception should be of the type related to the constraint that + failed. E.g. When a int is to be set with a value that + """ + for constraint_name in self.get_constraints(): + constraint = getattr(self, constraint_name) + constraint.validate(value) + return value + +class TAddonListProperty(TutoriusProperty): + """ + Stores an addon component list as a property. + The purpose of this class is to allow correct mapping of properties + through encapsulated hierarchies. + """ + pass + +class TIntProperty(TutoriusProperty): + """ + Represents an integer. Can have an upper value limit and/or a lower value + limit. + """ + + def __init__(self, value, lower_limit=None, upper_limit=None): + TutoriusProperty.__init__(self) + self.type = "int" + self.upper_limit = UpperLimitConstraint(upper_limit) + self.lower_limit = LowerLimitConstraint(lower_limit) + + self.default = self.validate(value) + +class TFloatProperty(TutoriusProperty): + """ + Represents a floating point number. Can have an upper value limit and/or + a lower value limit. + """ + def __init__(self, value, lower_limit=None, upper_limit=None): + TutoriusProperty.__init__(self) + self.type = "float" + + self.upper_limit = UpperLimitConstraint(upper_limit) + self.lower_limit = LowerLimitConstraint(lower_limit) + + self.default = self.validate(value) + +class TStringProperty(TutoriusProperty): + """ + Represents a string. Can have a maximum size limit. + """ + def __init__(self, value, size_limit=None): + TutoriusProperty.__init__(self) + self.type = "string" + self.size_limit = MaxSizeConstraint(size_limit) + + self.default = self.validate(value) + +class TArrayProperty(TutoriusProperty): + """ + Represents an array of properties. Can have a maximum number of element + limit, but there are no constraints on the content of the array. + """ + def __init__(self, value, min_size_limit=None, max_size_limit=None): + TutoriusProperty.__init__(self) + self.type = "array" + self.max_size_limit = MaxSizeConstraint(max_size_limit) + self.min_size_limit = MinSizeConstraint(min_size_limit) + self.default = tuple(self.validate(value)) + + #Make this thing hashable + def __setstate__(self, state): + self.max_size_limit = MaxSizeConstraint(state["max_size_limit"]) + self.min_size_limit = MinSizeConstraint(state["min_size_limit"]) + self.value = state["value"] + + def __getstate__(self): + return dict( + max_size_limit=self.max_size_limit.limit, + min_size_limit=self.min_size_limit.limit, + value=self.value, + ) +class TColorProperty(TutoriusProperty): + """ + Represents a RGB color with 3 8-bit integer values. + + The value of the property is the array [R, G, B] + """ + def __init__(self, red=None, green=None, blue=None): + TutoriusProperty.__init__(self) + self.type = "color" + + self.color_constraint = ColorConstraint() + + self._red = red or 0 + self._green = green or 0 + self._blue = blue or 0 + + self.default = self.validate([self._red, self._green, self._blue]) + +class TFileProperty(TutoriusProperty): + """ + Represents a path to a file on the disk. + """ + def __init__(self, path): + """ + Defines the path to an existing file on disk file. + + For now, the path may be relative or absolute, as long as it exists on + the local machine. + TODO : Make sure that we have a file scheme that supports distribution + on other computers (LP 355197) + """ + TutoriusProperty.__init__(self) + + self.type = "file" + + self.file_constraint = FileConstraint() + + self.default = self.validate(path) + +class TEnumProperty(TutoriusProperty): + """ + Represents a value in a given enumeration. This means that the value will + always be one in the enumeration and nothing else. + + """ + def __init__(self, value, accepted_values): + """ + Creates the enumeration property. + + @param value The initial value of the enum. Must be part of + accepted_values + @param accepted_values A list of values that the property can take + """ + TutoriusProperty.__init__(self) + + self.type = "enum" + + self.enum_constraint = EnumConstraint(accepted_values) + + self.default = self.validate(value) + +class TBooleanProperty(TutoriusProperty): + """ + Represents a True or False value. + """ + def __init__(self, value=False): + TutoriusProperty.__init__(self) + + self.type = "boolean" + + self.boolean_constraint = BooleanConstraint() + + self.default = self.validate(value) + +class TUAMProperty(TutoriusProperty): + """ + Represents a widget of the interface by storing its UAM. + """ + def __init__(self, value=None): + TutoriusProperty.__init__(self) + + self.type = "uam" + + self.default = self.validate(value) + +class TAddonProperty(TutoriusProperty): + """ + Reprensents an embedded tutorius Addon Component (action, trigger, etc.) + The purpose of this class is to flag the container for proper + serialization, as the contained object can't be directly dumped to text + for its attributes to be saved. + """ + class NullAction(TPropContainer): + def do(self): pass + def undo(self): pass + + def __init__(self): + super(TAddonProperty, self).__init__() + self.type = "addon" + self.default = self.NullAction() + + def validate(self, value): + if isinstance(value, TPropContainer): + return super(TAddonProperty, self).validate(value) + raise ValueError("Expected TPropContainer instance as TaddonProperty value") + +class TEventType(TutoriusProperty): + """ + Represents an GUI signal for a widget. + """ + def __init__(self, value): + super(TEventType, self).__init__() + self.type = "gtk-signal" + + self.default = self.validate(value) + +class TAddonListProperty(TutoriusProperty): + """ + Reprensents an embedded tutorius Addon List Component. + See TAddonProperty + """ + def __init__(self): + TutoriusProperty.__init__(self) + self.type = "addonlist" + self.default = [] + + def validate(self, value): + if isinstance(value, list): + for component in value: + if not (isinstance(component, TPropContainer)): + raise ValueError("Expected a list of TPropContainer instances inside TAddonListProperty value, got a %s" % (str(type(component)))) + return value + raise ValueError("Value proposed to TAddonListProperty is not a list") + diff --git a/src/tutorius/service.py b/src/tutorius/service.py new file mode 100644 index 0000000..eb246a1 --- /dev/null +++ b/src/tutorius/service.py @@ -0,0 +1,85 @@ +import dbus + +from .engine import Engine +from .dbustools import remote_call + +_DBUS_SERVICE = "org.tutorius.Service" +_DBUS_PATH = "/org/tutorius/Service" +_DBUS_SERVICE_IFACE = "org.tutorius.Service" + +class Service(dbus.service.Object): + """ + Global tutorius entry point to control the whole system + """ + + def __init__(self): + bus = dbus.SessionBus() + bus_name = dbus.service.BusName(_DBUS_SERVICE, bus=bus) + dbus.service.Object.__init__(self, bus_name, _DBUS_PATH) + + self._engine = None + + def start(self): + """ Start the service itself + """ + # For the moment there is nothing to do + pass + + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="s", out_signature="") + def launch(self, tutorialID): + """ Launch a tutorial + @param tutorialID unique tutorial identifier used to retrieve it from the disk + """ + if self._engine == None: + self._engine = Engine() + self._engine.launch(tutorialID) + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="", out_signature="") + def stop(self): + """ Stop the current tutorial + """ + self._engine.stop() + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="", out_signature="") + def pause(self): + """ Interrupt the current tutorial and save its state in the journal + """ + self._engine.pause() + +class ServiceProxy: + """ Proxy to connect to the Service object, abstracting the DBus interface""" + + def __init__(self): + bus = dbus.SessionBus() + self._object = bus.get_object(_DBUS_SERVICE,_DBUS_PATH) + self._service = dbus.Interface(self._object, _DBUS_SERVICE_IFACE) + + def launch(self, tutorialID): + """ Launch a tutorial + @param tutorialID unique tutorial identifier used to retrieve it from the disk + """ + remote_call(self._service.launch, (tutorialID, ), block=False) + + def stop(self): + """ Stop the current tutorial + """ + remote_call(self._service.stop, (), block=False) + + def pause(self): + """ Interrupt the current tutorial and save its state in the journal + """ + remote_call(self._service.pause, (), block=False) + +if __name__ == "__main__": + import dbus.mainloop.glib + import gobject + + loop = gobject.MainLoop() + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + s = Service() + loop.run() + diff --git a/src/tutorius/services.py b/src/tutorius/services.py new file mode 100644 index 0000000..e7b17d8 --- /dev/null +++ b/src/tutorius/services.py @@ -0,0 +1,72 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Services + +This module supplies services to be used by States, FSMs, Actions and Filters. + +Services provided are: +-Access to the running activity +-Access to the running tutorial + +TODO: Passing the activity reference should be done by the Probe instead +of being a global variable. +""" + + +class ObjectStore(object): + #Begin Singleton code + instance=None + def __new__(cls): + if not ObjectStore.instance: + ObjectStore.instance = ObjectStore.__ObjectStore() + + return ObjectStore.instance + + #End Singleton code + class __ObjectStore(object): + """ + The Object Store is a singleton class that allows access to + the current runnign activity and tutorial. + """ + def __init__(self): + self._activity = None + self._tutorial = None + #self._fsm_path = [] + + def set_activity(self, activity): + """Setter for activity""" + self._activity = activity + + def get_activity(self): + """Getter for activity""" + return self._activity + + activity = property(fset=set_activity,fget=get_activity,doc="activity") + + def set_tutorial(self, tutorial): + """Setter for tutorial""" + self._tutorial = tutorial + + def get_tutorial(self): + """Getter for tutorial""" + return self._tutorial + + tutorial = property(fset=set_tutorial,fget=get_tutorial,doc="tutorial") + + __doc__ = __ObjectStore.__doc__ + diff --git a/src/tutorius/store.py b/src/tutorius/store.py new file mode 100644 index 0000000..480c81b --- /dev/null +++ b/src/tutorius/store.py @@ -0,0 +1,173 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import urllib + +class StoreProxy(object): + """ + Implements a communication channel with the Tutorius Store, where tutorials + are shared from around the world. This proxy is meant to offer a one-stop + shop to implement all the requests that could be made to the Store. + """ + + def get_categories(self): + """ + Returns all the categories registered in the store. Categories are used to + classify tutorials according to a theme. (e.g. Mathematics, History, etc...) + + @return The list of category names stored on the server. + """ + raise NotImplementedError("get_categories() not implemented") + + def get_tutorials(self, keywords=None, category=None, startIndex=0, numResults=10, sortBy='name'): + """ + Returns the list of tutorials that correspond to the given search criteria. + + @param keywords The list of keywords that should be matched inside the tutorial title + or description. If None, the search will not filter the results + according to the keywords. + @param category The category in which to restrict the search. + @param startIndex The index in the result set from which to return results. This is + used to allow applications to fetch results one set at a time. + @param numResults The max number of results that can be returned + @param sortBy The field on which to sort the results + @return A list of tutorial meta-data that corresponds to the query + """ + raise NotImplementedError("get_tutorials() not implemented") + + def get_tutorial_collection(self, collection_name): + """ + Returns a list of tutorials corresponding to the given collection name. + Collections can be groups like '5 most downloaded' or 'Top 10 ratings'. + + @param collection_name The name of the collection from which we want the + meta-data + @return A list of tutorial meta-data corresponding to the given group + """ + raise NotImplementedError("get_tutorial_collection() not implemented... yet!") + + def get_latest_version(self, tutorial_id_list): + """ + Returns the latest version number on the server, for each tutorial ID + in the list. + + @param tutorial_id_list The list of tutorial IDs from which we want to + known the latest version number. + @return A dictionary having the tutorial ID as the key and the version + as the value. + """ + raise NotImplementedError("get_latest_version() not implemented") + + def download_tutorial(self, tutorial_id, version=None): + """ + Fetches the tutorial file from the server and returns the + + @param tutorial_id The tutorial that we want to get + @param version The version number that we want to download. If None, + the latest version will be downloaded. + @return The downloaded file itself (an in-memory representation of the file, + not a path to it on the disk) + + TODO : We should decide if we're saving to disk or in mem. + """ + raise NotImplementedError("downloadTutorial() not implemented") + + def login(self, username, password): + """ + Logs in the user on the store and saves the login status in the proxy + state. After a successful logon, the operation requiring a login will + be successful. + + @return True if the login was successful, False otherwise + """ + raise NotImplementedError("login() not implemented yet") + + def close_session(self): + """ + Ends the user's session on the server and changes the state of the proxy + to disallow the calls to the store that requires to be logged in. + + @return True if the user was disconnected, False otherwise + """ + raise NotImplementedError("close_session() not implemented yet") + + def get_session_id(self): + """ + Gives the current session ID cached in the Store Proxy, or returns + None is the user is not logged yet. + + @return The current session's ID, or None if the user is not logged + """ + raise NotImplementedError("get_session_id() not implemented yet") + + def rate(self, value, tutorial_store_id): + """ + Sends a rating for the given tutorial. + + This function requires the user to be logged in. + + @param value The value of the rating. It must be an integer with a value + from 1 to 5. + @param tutorial_store_id The ID of the tutorial that was rated + @return True if the rating was sent to the Store, False otherwise. + """ + raise NotImplementedError("rate() not implemented") + + def publish(self, tutorial): + """ + Sends a tutorial to the store. + + This function requires the user to be logged in. + + @param tutorial The tutorial file to be sent. Note that this is the + content itself and not the path to the file. + @return True if the tutorial was sent correctly, False otherwise. + """ + raise NotImplemetedError("publish() not implemented") + + def unpublish(self, tutorial_store_id): + """ + Removes a tutorial from the server. The user in the current session + needs to be the creator for it to be unpublished. This will remove + the file from the server and from all its collections and categories. + + This function requires the user to be logged in. + + @param tutorial_store_id The ID of the tutorial to be removed + @return True if the tutorial was properly removed from the server + """ + raise NotImplementedError("unpublish() not implemeted") + + def update_published_tutorial(self, tutorial_id, tutorial): + """ + Sends the new content for the tutorial with the given ID. + + This function requires the user to be logged in. + + @param tutorial_id The ID of the tutorial to be updated + @param tutorial The bundled tutorial file content (not a path!) + @return True if the tutorial was sent and updated, False otherwise + """ + raise NotImplementedError("update_published_tutorial() not implemented yet") + + def register_new_user(self, user_info): + """ + Creates a new user from the given user information. + + @param user_info A structure containing all the data required to do a login. + @return True if the new account was created, false otherwise + """ + raise NotImplementedError("register_new_user() not implemented") diff --git a/src/tutorius/testwin.py b/src/tutorius/testwin.py new file mode 100644 index 0000000..ef92b7f --- /dev/null +++ b/src/tutorius/testwin.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gtk +import dragbox +import textbubble + +box = None + +def _destroy(widget, data=None): + gtk.main_quit() + +def _delete_event(widget, event, data=None): + print "quitting" + return False + +def blublu(widget, data=""): + print data + +def _drag_toggle(widget, data=None): + global box + box.dragMode = not box.dragMode + + +def addBtn(widget, data, bubble=0, btns=[0]): + if bubble == 1: + bt = textbubble.TextBubble("Bubble(%d)"%btns[0]) + else: + bt = gtk.Button("Bubble(%d)"%btns[0]) + ##bt.set_size_request(60,40) + bt.show() + data.attach(bt) + btns[0] += 1 + +def main(): + global box + win = gtk.Window(type=gtk.WINDOW_TOPLEVEL) + win.connect("delete_event", _delete_event) + win.connect("destroy", _destroy) + + win.set_default_size(800,600) + + vbox = gtk.VBox() + vbox.show() + win.add(vbox) + + check = gtk.CheckButton(label="dragMode") + check.connect("toggled", _drag_toggle) + check.show() + vbox.pack_start(check, expand=False) + + btnadd = gtk.Button("Add Bubble") + btnadd.show() + vbox.pack_start(btnadd, expand=False) + btnadd2 = gtk.Button("Add Button") + btnadd2.show() + vbox.pack_start(btnadd2, expand=False) + +## bubble = textbubble.TextBubble("Bubbles!") +## bubble.show() +## bubble.set_size_request(40,40) +## vbox.pack_start(bubble, expand=False) + + box = dragbox.DragBox() + box.set_border_width(10) + box.show() + vbox.pack_start(box, expand=True, fill=True) + + btnadd.connect("clicked", addBtn, box, 1) + btnadd2.connect("clicked", addBtn, box) + + win.show() + gtk.main() + + +if __name__ == "__main__": + main() + diff --git a/src/tutorius/textbubble.py b/src/tutorius/textbubble.py new file mode 100644 index 0000000..e09b298 --- /dev/null +++ b/src/tutorius/textbubble.py @@ -0,0 +1,109 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module represents TextBubble widget. Also, it aims to be a short example +of drawing with Cairo. +""" + +import gtk +from math import pi as M_PI +import cairo + +# FIXME set as subclass of gtk.Widget, not EventBox +class TextBubble(gtk.EventBox): + def __init__(self, label): + gtk.EventBox.__init__(self) + + ##self.set_app_paintable(True) # else may be blank + # FIXME ensure previous call does not interfere with widget stacking + self.label = label + self.lineWidth = 5 + + self.connect("expose-event", self._on_expose) + + def __draw_with_cairo__(self, context): + """ + + """ + pass + + def _on_expose(self, widget, event): + """Redraw event callback.""" + # TODO + ctx = self.window.cairo_create() + + # set drawing region. Useless since this widget has its own window. + ##region = gtk.gdk.region_rectangle(self.allocation) + ##region.intersect(gtk.gdk.region_rectangle(event.area)) + ##ctx.region(region) + ##ctx.rectangle(event.area.x, event.area.y, event.area.width, event.area.height) + ##ctx.clip() + + ##import pdb; pdb.set_trace() + ##ctx.set_operator(cairo.OPERATOR_CLEAR) + ##ctx.paint() + ##ctx.set_operator(cairo.OPERATOR_OVER) + + width = self.allocation.width + height = self.allocation.height + xradius = width/2 + yradius = height/2 + width -= self.lineWidth + height -= self.lineWidth + ctx.move_to(self.lineWidth, yradius) + ctx.curve_to(self.lineWidth, self.lineWidth, + self.lineWidth, self.lineWidth, xradius, self.lineWidth) + ctx.curve_to(width, self.lineWidth, + width, self.lineWidth, width, yradius) + ctx.curve_to(width, height, width, height, xradius, height) + ctx.curve_to(self.lineWidth, height, + self.lineWidth, height, self.lineWidth, yradius) + ctx.set_source_rgb(1.0, 1.0, 1.0) + ctx.fill_preserve() + ctx.set_line_width(self.lineWidth) + ctx.set_source_rgb(0.0, 0.0, 0.0) + ctx.stroke() + + _, _, textWidth, textHeight, _, _ = ctx.text_extents(self._label) + ctx.move_to(int((self.allocation.width-textWidth)/2), + int((self.allocation.height+textHeight)/2)) + ctx.text_path(self._label) + ctx.fill() + + return True + + + def _set_label(self, value): + """Sets the label and flags the widget to be redrawn.""" + self._label = value + # FIXME hack to calculate size. necessary because may not have been + # realized + surf = cairo.SVGSurface("/dev/null", 0, 0) + ctx = cairo.Context(surf) + _, _, width, height, _, _ = ctx.text_extents(self._label) + del ctx, surf + + # FIXME bogus values follows + self.set_size_request(int(width+20), int(height+40)) + # TODO test changing a realized label + + def _get_label(self): + """Getter method for the label property""" + return self._label + + label = property(fget=_get_label, fset=_set_label,\ + doc="Text label which is to be painted on the top of the widget") + diff --git a/src/tutorius/uam/__init__.py b/src/tutorius/uam/__init__.py new file mode 100644 index 0000000..bcd67e1 --- /dev/null +++ b/src/tutorius/uam/__init__.py @@ -0,0 +1,89 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Universal Addressing Mechanism module + +Allows addressing Events, signals, widgets, etc for supported platforms +""" + +from urllib2 import urlparse + +import gtkparser +import gobjectparser + + +SCHEME="tap" #Tutorius Adressing Protocol + +__parsers = { + gtkparser.SCHEME:gtkparser.parse_gtk, + gobjectparser.SCHEME:gobjectparser.parse_gobject, +} + +def __add_to_urlparse(name): + #Add to uses_netloc + if not name in urlparse.uses_netloc: + urlparse.uses_netloc.append(name) + + #Add to uses_relative + if not name in urlparse.uses_relative: + urlparse.uses_relative.append(name) + +# #Add to uses_params +# if not name in urlparse.uses_params: +# urlparse.uses_params.append(name) + + #Add to uses_query + if not name in urlparse.uses_query: + urlparse.uses_query.append(name) + + #Add to uses_frament + if not name in urlparse.uses_fragment: + urlparse.uses_fragment.append(name) + + +#Add schemes to urlparse +__add_to_urlparse(SCHEME) + +for subscheme in [".".join([SCHEME,s]) for s in __parsers]: + __add_to_urlparse(subscheme) + + +class SchemeError(Exception): + def __init__(self, message): + Exception.__init__(self, message) + ## Commenting this line as it is causing an error in the tests + ##self.message = message + + +def parse_uri(uri): + res = urlparse.urlparse(uri) + + scheme = res.scheme.split(".")[0] + subscheme = ".".join(res.scheme.split(".")[1:]) + if not scheme == SCHEME: + raise SchemeError("Scheme %s not supported" % scheme) + + if subscheme != "" and not subscheme in __parsers: + raise SchemeError("SubScheme %s not supported" % subscheme) + + if subscheme: + return __parsers[subscheme](res) + + return res + + + diff --git a/src/tutorius/uam/gobjectparser.py b/src/tutorius/uam/gobjectparser.py new file mode 100644 index 0000000..c1fba3d --- /dev/null +++ b/src/tutorius/uam/gobjectparser.py @@ -0,0 +1,27 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +UAM Parser for gobject subscheme + +To be completed +""" + +SCHEME="gobject" + +def parse_gobject(parsed_uri): + """Do nothing for now""" + return parsed_uri diff --git a/src/tutorius/uam/gtkparser.py b/src/tutorius/uam/gtkparser.py new file mode 100644 index 0000000..ede2f03 --- /dev/null +++ b/src/tutorius/uam/gtkparser.py @@ -0,0 +1,44 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +UAM Parser for gtk subscheme + +Allows addressing Gtk Events, signals, widgets + +The gtk subscheme for tutorius is + +:///[?#] + +where: + + is the uam.SCHEME + "." + SCHEME + + is the activity's dns identifier, such as battleship.tutorius.org + + is the Hierarchical path to the widget, where 0 is the activity, such as /0/0/1/0/1/0 + + can be used to specify additionnal parameters required for an event handler or action, such as event=clicked + + must be used with params to specify which action or eventfilter to use, such as "DialogMessage" + +""" + +SCHEME="gtk" + +def parse_gtk(parsed_uri): + """Do nothing for now""" + return parsed_uri diff --git a/src/tutorius/vault.py b/src/tutorius/vault.py new file mode 100644 index 0000000..45f8184 --- /dev/null +++ b/src/tutorius/vault.py @@ -0,0 +1,860 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Jean-Christophe Savard +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +""" +This module contains all the data handling class of Tutorius +""" + +import logging +import os +import shutil +import tempfile +import uuid +import xml.dom.minidom +from xml.dom import NotFoundErr +import zipfile +from ConfigParser import SafeConfigParser + +from . import addon +from .core import Tutorial, State, FiniteStateMachine + +logger = logging.getLogger("tutorius") + +# this is where user installed/generated tutorials will go +def _get_store_root(): + profile_name = os.getenv("SUGAR_PROFILE") or "default" + return os.path.join(os.getenv("HOME"), + ".sugar",profile_name,"tutorius","data") +# this is where activity bundled tutorials should be, under the activity bundle +def _get_bundle_root(): + """ + Return the path of the bundled activity, or None if not applicable. + """ + if os.getenv("SUGAR_BUNDLE_PATH") != None: + return os.path.join(os.getenv("SUGAR_BUNDLE_PATH"),"data","tutorius","data") + else: + return None + +INI_ACTIVITY_SECTION = "RELATED_ACTIVITIES" +INI_METADATA_SECTION = "GENERAL_METADATA" +INI_GUID_PROPERTY = "guid" +INI_NAME_PROPERTY = "name" +INI_XML_FSM_PROPERTY = "fsm_filename" +INI_VERSION_PROPERTY = 'version' +INI_FILENAME = "meta.ini" +TUTORIAL_FILENAME = "tutorial.xml" +NODE_COMPONENT = "Component" +NODE_SUBCOMPONENT = "property" +NODE_SUBCOMPONENTLIST = "listproperty" +NEXT_STATE_ATTR = "next_state" + +class Vault(object): + + ## Vault internal functions : + @staticmethod + def list_available_tutorials(activity_name = None, activity_vers = 0): + """ + Generate the list of all tutorials present on disk for a + given activity. + + @param activity_name the name of the activity associated with this tutorial. None means ALL activities + @param activity_vers the version number of the activity to find tutorail for. 0 means find for ANY version. Ifactivity_ame is None, version number is not used + @returns a map of tutorial {names : GUID}. + """ + # check both under the activity data and user installed folders + if _get_bundle_root() != None: + paths = [_get_store_root(), _get_bundle_root()] + else: + paths = [_get_store_root()] + + tutoGuidName = {} + + for repository in paths: + # (our) convention dictates that tutorial folders are named + # with their GUID (for unicity) + try: + for tuto in os.listdir(repository): + parser = SafeConfigParser() + file = parser.read(os.path.join(repository, tuto, INI_FILENAME)) + if file != []: + # If parser can read at least section + guid = parser.get(INI_METADATA_SECTION, INI_GUID_PROPERTY) + name = parser.get(INI_METADATA_SECTION, INI_NAME_PROPERTY) + activities = parser.options(INI_ACTIVITY_SECTION) + # enforce matching activity name AND version, as UI changes + # break tutorials. We may lower this requirement when the + # UAM gets less dependent on the widget order. + # Also note property names are always stored lowercase. + if (activity_name != None) and (activity_name.lower() in activities): + version = parser.get(INI_ACTIVITY_SECTION, activity_name) + if (activity_vers == version) or (activity_vers == 0): + tutoGuidName[guid] = name + elif (activity_name == None): + tutoGuidName[guid] = name + except OSError: + # the repository may not exist. Continue scanning + pass + + return tutoGuidName + + ## Vault interface functions : + @staticmethod + def installTutorials(path, zip_file_name, forceinstall=False): + """ + Extract the tutorial files in the ZIPPED tutorial archive at the + specified path and add them inside the vault. This should remove any previous + version of this tutorial, if there's any. On the opposite, if we are + trying to install an earlier version, the function will return 1 if + forceInstall is not set to true. + + @params path The path where the zipped tutorial archive is present + @params forceinstall A flag that indicate if we need to force overwrite + of a tutorial even if is version number is lower than the existing one. + + @returns 0 if it worked, 1 if the user needs to confirm the installation + and 2 to mean an error happened + """ + # TODO : Check with architecture team for exception vs error returns + + # test if the file is a valid pkzip file + if zipfile.is_zipfile(os.path.join(path, zip_file_name)) != True: + assert False, "Error : The given file is not a valid PKZip file" + + # unpack the zip archive + zfile = zipfile.ZipFile(os.path.join(path, zip_file_name), "r" ) + + temp_path = tempfile.mkdtemp(dir=_get_store_root()) + zfile.extractall(temp_path) + + # get the tutorial file + ini_file_path = os.path.join(temp_path, INI_FILENAME) + ini_file = SafeConfigParser() + ini_file.read(ini_file_path) + + # get the tutorial guid + guid = ini_file.get(INI_METADATA_SECTION, INI_GUID_PROPERTY) + + # Check if tutorial already exist + tutorial_path = os.path.join(_get_store_root(), guid) + if os.path.isdir(tutorial_path) == False: + # Copy the tutorial in the Vault + shutil.copytree(temp_path, tutorial_path) + + else: + # Check the version of the existing tutorial + existing_version = ini_file.get(INI_METADATA_SECTION, INI_VERSION_PROPERTY) + # Check the version of the new tutorial + new_ini_file = SafeConfigParser() + new_ini_file.read(os.path.join(tutorial_path, INI_FILENAME)) + new_version = new_ini_file.get(INI_METADATA_SECTION, INI_VERSION_PROPERTY) + + if new_version < existing_version and forceinstall == False: + # Version of new tutorial is older and forceinstall is false, return exception + return 1 + else : + # New tutorial is newer or forceinstall flag is set, can overwrite the existing tutorial + shutil.rmtree(tutorial_path) + shutil.copytree(temp_path, tutorial_path) + + # Remove temp data + shutil.rmtree(temp_path) + + return 0 + + @staticmethod + def query(keyword=[], relatedActivityNames=[], category=[]): + """ + Returns the list of tutorials that corresponds to the specified parameters. + + @returns a list of Tutorial meta-data (TutorialID, Description, + Rating, Category, PublishState, etc...) + TODO : Search for tuto caracterised by the entry : OR between [], and between each + + The returned dictionnary is of this format : key = property name, value = property value + The dictionnary also contain one dictionnary element whose key is the string 'activities' + and whose value is another dictionnary of this form : key = related activity name, + value = related activity version number + """ + + # Temp solution for returning all tutorials metadata + + tutorial_list = [] + tuto_guid_list = [] + ini_file = SafeConfigParser() + if keyword == [] and relatedActivityNames == [] and category == []: + # get all tutorials tuples (name:guid) for all activities and version + tuto_dict = Vault.list_available_tutorials() + for id in tuto_dict.keys(): + tuto_guid_list.append(id) + + # Find .ini metadata files with the guid list + + # Get the guid from the tuto tuples + for guid in tuto_guid_list: + # Create a dictionnary containing the metadata and also + # another dictionnary containing the tutorial Related Acttivities, + # and add it to a list + + # Create a TutorialBundler object from the guid + bundler = TutorialBundler(guid) + # Find the .ini file path for this guid + ini_file_path = bundler.get_tutorial_path(guid) + # Read the .ini file + ini_file.read(os.path.join(ini_file_path, 'meta.ini')) + + metadata_dictionnary = {} + related_act_dictionnary = {} + metadata_list = ini_file.options(INI_METADATA_SECTION) + for metadata_name in metadata_list: + # Create a dictionnary of tutorial metadata + metadata_dictionnary[metadata_name] = ini_file.get(INI_METADATA_SECTION, metadata_name) + # Get Related Activities data from.ini files + related_act_list = ini_file.options(INI_ACTIVITY_SECTION) + for related_act in related_act_list: + # For related activites, the format is : key = activity name, value = activity version + related_act_dictionnary[related_act] = ini_file.get(INI_ACTIVITY_SECTION, related_act) + + # Add Related Activities dictionnary to metadata dictionnary + metadata_dictionnary['activities'] = related_act_dictionnary + + # Add this dictionnary to tutorial list + tutorial_list.append(metadata_dictionnary) + + # Return tutorial list + return tutorial_list + + @staticmethod + def loadTutorial(Guid): + """ + Creates an executable version of a tutorial from its saved representation. + @returns an executable representation of a tutorial + """ + + bundle = TutorialBundler(Guid) + bundle_path = bundle.get_tutorial_path(Guid) + config = SafeConfigParser() + config.read(os.path.join(bundle_path, INI_FILENAME)) + + serializer = XMLSerializer() + + name = config.get(INI_METADATA_SECTION, INI_NAME_PROPERTY) + fsm = serializer.load_fsm(Guid, bundle_path) + + tuto = Tutorial(name, fsm) + return tuto + + @staticmethod + def saveTutorial(tutorial, metadata_dict): + """ + Creates a persistent version of a tutorial in the Vault. + @returns true if the tutorial was saved correctly + """ + + # Get the tutorial guid from metadata dictionnary + guid = metadata_dict[INI_GUID_PROPERTY] + + # Check if tutorial already exist + tutorial_path = os.path.join(_get_store_root(), guid) + if os.path.isdir(tutorial_path) == False: + + # Serialize the tutorial and write it to disk + xml_ser = XMLSerializer() + os.makedirs(tutorial_path) + xml_ser.save_fsm(tutorial.state_machine, TUTORIAL_FILENAME, tutorial_path) + + # Create the metadata file + ini_file_path = os.path.join(tutorial_path, "meta.ini") + parser = SafeConfigParser() + parser.add_section(INI_METADATA_SECTION) + for key, value in metadata_dict.items(): + if key != 'activities': + parser.set(INI_METADATA_SECTION, key, value) + else: + related_activities_dict = value + parser.add_section(INI_ACTIVITY_SECTION) + for related_key, related_value in related_activities_dict.items(): + parser.set(INI_ACTIVITY_SECTION, related_key, related_value) + + # Write the file to disk + with open(ini_file_path, 'wb') as configfile: + parser.write(configfile) + + else: + # Error, tutorial already exist + return False + + # TODO : wait for Ben input on how to unpublish tuto before coding this function + # For now, no unpublishing will occur. + + + @staticmethod + def deleteTutorial(Tutorial): + """ + Removes the tutorial from the Vault. It will unpublish the tutorial if need be, + and it will also wipe it from the persistent storage. + @returns true is the tutorial was deleted from the Vault + """ + bundle = TutorialBundler(Guid) + bundle_path = bundle.get_tutorial_path(Guid) + + # TODO : Need also to unpublish tutorial, need to interact with webservice module + + shutil.rmtree(bundle_path) + if os.path.isdir(bundle_path) == False: + return True + else: + return False + + +class Serializer(object): + """ + Interface that provide serializing and deserializing of the FSM + used in the tutorials to/from disk. Must be inherited. + """ + + def save_fsm(self,fsm): + """ + Save fsm to disk. If a GUID parameter is provided, the existing GUID is + located in the .ini files in the store root and bundle root and + the corresponding FSM is/are overwritten. If the GUId is not found, an + exception occur. If no GUID is provided, FSM is written in a new file + in the store root. + """ + raise NotImplementedError() + + def load_fsm(self): + """ + Load fsm from disk. + """ + raise NotImplementedError() + +class XMLSerializer(Serializer): + """ + Class that provide serializing and deserializing of the FSM + used in the tutorials to/from a .xml file. Inherit from Serializer + """ + + def _create_state_dict_node(self, state_dict, doc): + """ + Create and return a xml Node from a State dictionnary. + """ + statesList = doc.createElement("States") + for state_name, state in state_dict.items(): + stateNode = doc.createElement("State") + statesList.appendChild(stateNode) + stateNode.setAttribute("Name", state_name) + actionsList = stateNode.appendChild(self._create_action_list_node(state.get_action_list(), doc)) + eventfiltersList = stateNode.appendChild(self._create_event_filters_node(state.get_event_filter_list(), doc)) + return statesList + + def _create_addon_component_node(self, parent_attr_name, comp, doc): + """ + Takes a component that is embedded in another component (e.g. the content + of a OnceWrapper) and encapsulate it in a node with the property name. + + e.g. + + + + + + + When reloading this node, we should look up the property name for the parent + in the attribute of the node, then examine the subnode to create the addon + object itself. + + @param parent_attr_name The name of the parent's attribute for this addon + e.g. the OnceWrapper has the action attribute, which corresponds to a + sub-action it must execute once. + @param comp The component node itself + @param doc The XML document root (only used to create the nodes) + @returns A NODE_SUBCOMPONENT node, with the property attribute and a sub node + that represents another component. + """ + subCompNode = doc.createElement(NODE_SUBCOMPONENT) + subCompNode.setAttribute("name", parent_attr_name) + + subNode = self._create_component_node(comp, doc) + + subCompNode.appendChild(subNode) + + return subCompNode + + def _create_addonlist_component_node(self, parent_attr_name, comp_list, doc): + """ + Takes a list of components that are embedded in another component (ex. the + content of a ChainAction) and encapsulate them in a node with the property + name. + + e.g. + + + + + + + + When reloading this node, we should look up the property name for the parent + in the the attribute of the node, then rebuild the list by appending the + content of all the subnodes. + + @param parent_attr_name The name of the parent component's property + @param comp_list A list of components that comprise the property + @param doc The XML document root (only for creating new nodes) + @returns A NODE_SUBCOMPONENTLIST node with the property attribute + """ + subCompListNode = doc.createElement(NODE_SUBCOMPONENTLIST) + subCompListNode.setAttribute("name", parent_attr_name) + + for comp in comp_list: + compNode = self._create_component_node(comp, doc) + subCompListNode.appendChild(compNode) + + return subCompListNode + + def _create_component_node(self, comp, doc): + """ + Takes a single component (action or eventfilter) and transforms it + into a xml node. + + @param comp A single component + @param doc The XML document root (used to create nodes only + @return A XML Node object with the component tag name + """ + compNode = doc.createElement(NODE_COMPONENT) + + # Write down just the name of the Action class as the Class + # property -- + compNode.setAttribute("Class",type(comp).__name__) + + # serialize all tutorius properties + for propname in comp.get_properties(): + propval = getattr(comp, propname) + if getattr(type(comp), propname).type == "addonlist": + compNode.appendChild(self._create_addonlist_component_node(propname, propval, doc)) + elif getattr(type(comp), propname).type == "addon": + #import rpdb2; rpdb2.start_embedded_debugger('pass') + compNode.appendChild(self._create_addon_component_node(propname, propval, doc)) + else: + # repr instead of str, as we want to be able to eval() it into a + # valid object. + compNode.setAttribute(propname, repr(propval)) + + return compNode + + def _create_action_list_node(self, action_list, doc): + """ + Create and return a xml Node from a Action list. + + @param action_list A list of actions + @param doc The XML document root (used to create new nodes only) + @return A XML Node object with the Actions tag name and a serie of + Action children + """ + actionsList = doc.createElement("Actions") + for action in action_list: + # Create the action node + actionNode = self._create_component_node(action, doc) + # Append it to the list + actionsList.appendChild(actionNode) + + return actionsList + + def _create_event_filters_node(self, event_filters, doc): + """ + Create and return a xml Node from an event filters. + """ + eventFiltersList = doc.createElement("EventFiltersList") + for event, state in event_filters: + eventFilterNode = self._create_component_node(event, doc) + eventFilterNode.setAttribute(NEXT_STATE_ATTR, str(state)) + eventFiltersList.appendChild(eventFilterNode) + + return eventFiltersList + + def save_fsm(self, fsm, xml_filename, path): + """ + Save fsm to disk, in the xml file specified by "xml_filename", in the + "path" folder. If the specified file doesn't exist, it will be created. + """ + self.doc = doc = xml.dom.minidom.Document() + fsm_element = doc.createElement("FSM") + doc.appendChild(fsm_element) + fsm_element.setAttribute("Name", fsm.name) + fsm_element.setAttribute("StartStateName", fsm.start_state_name) + statesDict = fsm_element.appendChild(self._create_state_dict_node(fsm._states, doc)) + + fsm_actions_node = self._create_action_list_node(fsm.actions, doc) + fsm_actions_node.tagName = "FSMActions" + actionsList = fsm_element.appendChild(fsm_actions_node) + + file_object = open(os.path.join(path, xml_filename), "w") + file_object.write(doc.toprettyxml()) + file_object.close() + + def _get_direct_descendants_by_tag_name(self, node, name): + """ + Searches in the list of direct descendants of a node to find all the node + that have the given name. + + This is used because the Document.getElementsByTagName() function returns the + list of all the descendants (whatever their distance to the start node) that + have that name. In the case of complex components, we absolutely need to inspect + a single layer of the tree at the time. + + @param node The node from which we want the direct descendants with a particular + name + @param name The name of the node + @returns A list, possibly empty, of direct descendants of node that have this name + """ + return_list = [] + for childNode in node.childNodes: + if childNode.nodeName == name: + return_list.append(childNode) + return return_list + + +## def _load_xml_properties(self, properties_elem): +## """ +## Changes a list of properties into fully instanciated properties. +## +## @param properties_elem An XML element reprensenting a list of +## properties +## """ +## return [] + + def _load_xml_event_filters(self, filters_elem): + """ + Loads up a list of Event Filters. + + @param filters_elem An XML Element representing a list of event filters + """ + transition_list = [] + event_filter_element_list = self._get_direct_descendants_by_tag_name(filters_elem, NODE_COMPONENT) + new_event_filter = None + + for event_filter in event_filter_element_list: + next_state = event_filter.getAttribute(NEXT_STATE_ATTR) + try: + event_filter.removeAttribute(NEXT_STATE_ATTR) + except NotFoundErr: + next_state = None + new_event_filter = self._load_xml_component(event_filter) + + if new_event_filter is not None: + transition_list.append((new_event_filter, next_state)) + + return transition_list + + def _load_xml_subcomponents(self, node, properties): + """ + Loads all the subcomponent node below the given node and inserts them with + the right property name inside the properties dictionnary. + + @param node The parent node that contains one or many property nodes. + @param properties A dictionnary where the subcomponent property names + and the instantiated components will be stored + @returns Nothing. The properties dict will contain the property->comp mapping. + """ + subCompList = self._get_direct_descendants_by_tag_name(node, NODE_SUBCOMPONENT) + + for subComp in subCompList: + property_name = subComp.getAttribute("name") + internal_comp_node = self._get_direct_descendants_by_tag_name(subComp, NODE_COMPONENT)[0] + internal_comp = self._load_xml_component(internal_comp_node) + properties[str(property_name)] = internal_comp + + def _load_xml_subcomponent_lists(self, node, properties): + """ + Loads all the subcomponent lists below the given node and stores them + under the correct property name for that node. + + @param node The node from which we want to read the subComponent lists + @param properties The dictionnary that will contain the mapping of prop->subCompList + @returns Nothing. The values are returns inside the properties dict. + """ + listOf_subCompListNode = self._get_direct_descendants_by_tag_name(node, NODE_SUBCOMPONENTLIST) + for subCompListNode in listOf_subCompListNode: + property_name = subCompListNode.getAttribute("name") + subCompList = [] + for subCompNode in self._get_direct_descendants_by_tag_name(subCompListNode, NODE_COMPONENT): + subComp = self._load_xml_component(subCompNode) + subCompList.append(subComp) + properties[str(property_name)] = subCompList + + def _load_xml_component(self, node): + """ + Loads a single addon component instance from an Xml node. + + @param node The component XML Node to transform + object + @return The addon component object of the correct type according to the XML + description + """ + class_name = node.getAttribute("Class") + + properties = {} + + for prop in node.attributes.keys(): + if prop == "Class" : continue + # security : keep sandboxed + properties[str(prop)] = eval(node.getAttribute(prop)) + + # Read the complex attributes + self._load_xml_subcomponents(node, properties) + self._load_xml_subcomponent_lists(node, properties) + + new_action = addon.create(class_name, **properties) + + if not new_action: + return None + + return new_action + + def _load_xml_actions(self, actions_elem): + """ + Transforms an Actions element into a list of instanciated Action. + + @param actions_elem An XML Element representing a list of Actions + """ + reformed_actions_list = [] + actions_element_list = self._get_direct_descendants_by_tag_name(actions_elem, NODE_COMPONENT) + + for action in actions_element_list: + new_action = self._load_xml_component(action) + + reformed_actions_list.append(new_action) + + return reformed_actions_list + + def _load_xml_states(self, states_elem): + """ + Takes in a States element and fleshes out a complete list of State + objects. + + @param states_elem An XML Element that represents a list of States + """ + reformed_state_list = [] + # item(0) because there is always only one tag in the xml file + # so states_elem should always contain only one element + states_element_list = states_elem.item(0).getElementsByTagName("State") + + for state in states_element_list: + stateName = state.getAttribute("Name") + # Using item 0 in the list because there is always only one + # Actions and EventFilterList element per State node. + actions_list = self._load_xml_actions(state.getElementsByTagName("Actions")[0]) + event_filters_list = self._load_xml_event_filters(state.getElementsByTagName("EventFiltersList")[0]) + reformed_state_list.append(State(stateName, actions_list, event_filters_list)) + + return reformed_state_list + + def load_xml_fsm(self, fsm_elem): + """ + Takes in an XML element representing an FSM and returns the fully + crafted FSM. + + @param fsm_elem The XML element that describes a FSM + """ + # Load the FSM's name and start state's name + fsm_name = fsm_elem.getAttribute("Name") + + fsm_start_state_name = None + try: + fsm_start_state_name = fsm_elem.getAttribute("StartStateName") + except: + pass + + fsm = FiniteStateMachine(fsm_name, start_state_name=fsm_start_state_name) + + # Load the states + states = self._load_xml_states(fsm_elem.getElementsByTagName("States")) + for state in states: + fsm.add_state(state) + + # Load the actions on this FSM + actions = self._load_xml_actions(fsm_elem.getElementsByTagName("FSMActions")[0]) + for action in actions: + fsm.add_action(action) + + # Load the event filters + events = self._load_xml_event_filters(fsm_elem.getElementsByTagName("EventFiltersList")[0]) + for event, next_state in events: + fsm.add_event_filter(event, next_state) + + return fsm + + + def load_fsm(self, guid, path=None): + """ + Load fsm from xml file whose .ini file guid match argument guid. + """ + # Fetch the directory (if any) + bundler = TutorialBundler(guid) + tutorial_dir = bundler.get_tutorial_path(guid) + + # Open the XML file + tutorial_file = os.path.join(tutorial_dir, TUTORIAL_FILENAME) + + xml_dom = xml.dom.minidom.parse(tutorial_file) + + fsm_elem = xml_dom.getElementsByTagName("FSM")[0] + + return self.load_xml_fsm(fsm_elem) + + +class TutorialBundler(object): + """ + This class provide the various data handling methods useable by the tutorial + editor. + """ + + def __init__(self,generated_guid = None, bundle_path=None): + """ + Tutorial_bundler constructor. If a GUID is given in the parameter, the + Tutorial_bundler object will be associated with it. If no GUID is given, + a new GUID will be generated, + """ + + self.Guid = generated_guid or str(uuid.uuid1()) + + #FIXME: Look for the bundle in the activity first (more specific) + #Look for the file in the path if a uid is supplied + if generated_guid: + #General store + store_path = os.path.join(_get_store_root(), str(generated_guid), INI_FILENAME) + if os.path.isfile(store_path): + self.Path = os.path.dirname(store_path) + elif _get_bundle_root() != None: + #Bundle store + bundle_path = os.path.join(_get_bundle_root(), str(generated_guid), INI_FILENAME) + if os.path.isfile(bundle_path): + self.Path = os.path.dirname(bundle_path) + else: + raise IOError(2,"Unable to locate metadata file for guid '%s'" % generated_guid) + else: + raise IOError(2,"Unable to locate metadata file for guid '%s'" % generated_guid) + + else: + #Create the folder, any failure will go through to the caller for now + store_path = os.path.join(_get_store_root(), self.Guid) + os.makedirs(store_path) + self.Path = store_path + + def write_metadata_file(self, tutorial): + """ + Write metadata to the property file. + @param tutorial Tutorial for which to write metadata + """ + #Create the Config Object and populate it + cfg = SafeConfigParser() + cfg.add_section(INI_METADATA_SECTION) + cfg.set(INI_METADATA_SECTION, INI_GUID_PROPERTY, self.Guid) + cfg.set(INI_METADATA_SECTION, INI_NAME_PROPERTY, tutorial.name) + cfg.set(INI_METADATA_SECTION, INI_XML_FSM_PROPERTY, TUTORIAL_FILENAME) + cfg.add_section(INI_ACTIVITY_SECTION) + if os.environ['SUGAR_BUNDLE_NAME'] != None and os.environ['SUGAR_BUNDLE_VERSION'] != None: + cfg.set(INI_ACTIVITY_SECTION, os.environ['SUGAR_BUNDLE_NAME'], + os.environ['SUGAR_BUNDLE_VERSION']) + else: + cfg.set(INI_ACTIVITY_SECTION, 'not_an_activity', '0') + + #Write the ini file + cfg.write( file( os.path.join(self.Path, INI_FILENAME), 'w' ) ) + + + @staticmethod + def get_tutorial_path(guid): + """ + Finds the tutorial with the associated GUID. If it is found, return + the path to the tutorial's directory. If it doesn't exist, raise an + IOError. + + A note : if there are two tutorials with this GUID in the folders, + they will both be inspected and the one with the highest version + number will be returned. If they have the same version number, the one + from the global store will be returned. + + @param guid The GUID of the tutorial that is to be loaded. + """ + # Attempt to find the tutorial's directory in the global directory + global_dir = os.path.join(_get_store_root(),str(guid)) + # Then in the activty's bundle path + if _get_bundle_root() != None: + activity_dir = os.path.join(_get_bundle_root(), str(guid)) + else: + activity_dir = '' + + # If they both exist + if os.path.isdir(global_dir) and os.path.isdir(activity_dir): + # Inspect both metadata files + global_meta = os.path.join(global_dir, "meta.ini") + activity_meta = os.path.join(activity_dir, "meta.ini") + + # Open both config files + global_parser = SafeConfigParser() + global_parser.read(global_meta) + + activity_parser = SafeConfigParser() + activity_parser.read(activity_meta) + + # Get the version number for each tutorial + global_version = global_parser.get(INI_METADATA_SECTION, "version") + activity_version = activity_parser.get(INI_METADATA_SECTION, "version") + + # If the global version is higher or equal, we'll take it + if global_version >= activity_version: + return global_dir + else: + return activity_dir + + # Do we just have the global directory? + if os.path.isdir(global_dir): + return global_dir + + # Or just the activity's bundle directory? + if os.path.isdir(activity_dir): + return activity_dir + + # Error : none of these directories contain the tutorial + raise IOError(2, "Neither the global nor the bundle directory contained the tutorial with GUID %s"%guid) + + + def write_fsm(self, fsm): + + """ + Save fsm to disk. If a GUID parameter is provided, the existing GUID is + located in the .ini files in the store root and bundle root and + the corresponding FSM is/are created or overwritten. If the GUID is not + found, an exception occur. + """ + + config = SafeConfigParser() + + serializer = XMLSerializer() + path = os.path.join(self.Path, "meta.ini") + config.read(path) + xml_filename = config.get(INI_METADATA_SECTION, INI_XML_FSM_PROPERTY) + serializer.save_fsm(fsm, xml_filename, self.Path) + + @staticmethod + def add_resources(typename, file): + """ + Add ressources to metadata. + """ + raise NotImplementedError("add_resources not implemented") diff --git a/src/tutorius/viewer.py b/src/tutorius/viewer.py new file mode 100644 index 0000000..ab8b918 --- /dev/null +++ b/src/tutorius/viewer.py @@ -0,0 +1,424 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module renders a widget containing a graphical representation +of a tutorial and acts as a creator proxy as it has some editing +functionality. +""" +import sys + +import gtk, gtk.gdk +import cairo +from math import pi as PI +PI2 = PI/2 + +import rsvg + +from sugar.bundle import activitybundle +from sugar.tutorius import addon +from sugar.graphics import icon +from sugar.tutorius.filters import EventFilter +from sugar.tutorius.actions import Action +import os + +# FIXME ideally, apps scale correctly and we should use proportional positions +X_WIDTH = 800 +X_HEIGHT = 600 +ACTION_WIDTH = 100 +ACTION_HEIGHT = 70 + +# block look +BLOCK_PADDING = 5 +BLOCK_WIDTH = 100 +BLOCK_CORNERS = 10 +BLOCK_INNER_PAD = 10 + +SNAP_WIDTH = BLOCK_WIDTH - BLOCK_PADDING - BLOCK_INNER_PAD*2 +SNAP_HEIGHT = SNAP_WIDTH*X_HEIGHT/X_WIDTH +SNAP_SCALE = float(SNAP_WIDTH)/X_WIDTH + +class Viewer(object): + """ + Renders a tutorial as a sequence of blocks, each block representing either + an action or an event (transition). + + Current Viewer implementation lacks viewport management; + having many objects in a tutorial will not render properly. + """ + def __init__(self, tutorial, creator): + super(Viewer, self).__init__() + + self._tutorial = tutorial + self._creator = creator + self.alloc = None + self.click_pos = None + self.drag_pos = None + self.selection = [] + + self.win = gtk.Window(gtk.WINDOW_TOPLEVEL) + self.win.set_size_request(400, 200) + self.win.set_gravity(gtk.gdk.GRAVITY_SOUTH_WEST) + self.win.show() + self.win.set_deletable(False) + self.win.move(0, 0) + + vbox = gtk.ScrolledWindow() + self.win.add(vbox) + + canvas = gtk.DrawingArea() + vbox.add_with_viewport(canvas) + canvas.set_app_paintable(True) + canvas.connect_after("expose-event", self.on_viewer_expose, tutorial._states) + canvas.add_events(gtk.gdk.BUTTON_PRESS_MASK \ + |gtk.gdk.BUTTON_MOTION_MASK \ + |gtk.gdk.BUTTON_RELEASE_MASK \ + |gtk.gdk.KEY_PRESS_MASK) + canvas.connect('button-press-event', self._on_click) + # drag-select disabled, for now + #canvas.connect('motion-notify-event', self._on_drag) + canvas.connect('button-release-event', self._on_drag_end) + canvas.connect('key-press-event', self._on_key_press) + + canvas.set_flags(gtk.HAS_FOCUS|gtk.CAN_FOCUS) + canvas.grab_focus() + + self.win.show_all() + canvas.set_size_request(2048, 180) # FIXME + + def destroy(self): + self.win.destroy() + + + def _paint_state(self, ctx, states): + """ + Paints a tutorius fsm state in a cairo context. + Final context state will be shifted by the size of the graphics. + """ + block_width = BLOCK_WIDTH - BLOCK_PADDING + block_max_height = self.alloc.height + + new_insert_point = None + cur_state = 'INIT' + + # FIXME: get app when we have a model that supports it + cur_app = 'Calculate' + app_start = ctx.get_matrix() + try: + state = states[cur_state] + except KeyError: + state = None + + while state: + new_app = 'Calculate' + if new_app != cur_app: + ctx.save() + ctx.set_matrix(app_start) + self._render_app_hints(ctx, cur_app) + ctx.restore() + app_start = ctx.get_matrix() + ctx.translate(BLOCK_PADDING, 0) + cur_app = new_app + + action_list = state.get_action_list() + if action_list: + local_height = (block_max_height - BLOCK_PADDING)/len(action_list) - BLOCK_PADDING + ctx.save() + for action in action_list: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos and \ + self.click_pos[0]-BLOCK_WIDTHorigin[0]: + self.selection.append(action) + self.render_action(ctx, block_width, local_height, action) + ctx.translate(0, local_height+BLOCK_PADDING) + + ctx.restore() + ctx.translate(BLOCK_WIDTH, 0) + + # insertion cursor painting made from two opposed triangles + # joined by a line. + if state.name == self._creator.get_insertion_point(): + ctx.save() + bp2 = BLOCK_PADDING/2 + ctx.move_to(-bp2, 0) + ctx.line_to(-BLOCK_PADDING-bp2, -BLOCK_PADDING) + ctx.line_to(bp2, -BLOCK_PADDING) + ctx.line_to(-bp2, 0) + + ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING) + ctx.line_to(bp2, block_max_height-BLOCK_PADDING) + ctx.line_to(-BLOCK_PADDING-bp2, block_max_height-BLOCK_PADDING) + ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING) + + ctx.line_to(-bp2, BLOCK_PADDING) + ctx.set_source_rgb(1.0, 1.0, 0.0) + ctx.stroke_preserve() + ctx.fill() + ctx.restore() + + + event_list = state.get_event_filter_list() + if event_list: + local_height = (block_max_height - BLOCK_PADDING)/len(event_list) - BLOCK_PADDING + ctx.save() + for event, next_state in event_list: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos and \ + self.click_pos[0]-BLOCK_WIDTHorigin[0]: + self.selection.append(event) + self.render_event(ctx, block_width, local_height, event) + ctx.translate(0, local_height+BLOCK_PADDING) + + ctx.restore() + ctx.translate(BLOCK_WIDTH, 0) + + # FIXME point to next state in state, as it would highlight + # the "happy path". + cur_state = event_list[0][1] + + if (not new_insert_point) and self.click_pos: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos[0]