Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/tutorius
diff options
context:
space:
mode:
Diffstat (limited to 'tutorius')
-rw-r--r--tutorius/TProbe.py506
-rw-r--r--tutorius/actions.py153
-rw-r--r--tutorius/addon.py21
-rw-r--r--tutorius/bundler.py558
-rw-r--r--tutorius/constraints.py5
-rw-r--r--tutorius/core.py234
-rw-r--r--tutorius/creator.py630
-rw-r--r--tutorius/dbustools.py41
-rw-r--r--tutorius/engine.py48
-rw-r--r--tutorius/filters.py132
-rw-r--r--tutorius/linear_creator.py3
-rw-r--r--tutorius/overlayer.py14
-rw-r--r--tutorius/properties.py71
-rw-r--r--tutorius/service.py85
-rw-r--r--tutorius/services.py3
-rw-r--r--tutorius/store.py173
-rw-r--r--tutorius/uam/__init__.py3
-rw-r--r--tutorius/ui/creator.glade209
-rw-r--r--tutorius/vault.py861
-rw-r--r--tutorius/viewer.py406
20 files changed, 3051 insertions, 1105 deletions
diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py
new file mode 100644
index 0000000..6d7b6e2
--- /dev/null
+++ b/tutorius/TProbe.py
@@ -0,0 +1,506 @@
+import logging
+LOGGER = logging.getLogger("sugar.tutorius.TProbe")
+import os
+
+import gobject
+
+import dbus
+import dbus.service
+import cPickle as pickle
+
+import sugar.tutorius.addon as addon
+
+from sugar.tutorius.services import ObjectStore
+from sugar.tutorius.properties import TPropContainer
+
+from sugar.tutorius.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 not block:
+ raise RuntimeError("This function does not allow non-blocking mode yet")
+ 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/tutorius/actions.py b/tutorius/actions.py
index 4269cd7..08f55cd 100644
--- a/tutorius/actions.py
+++ b/tutorius/actions.py
@@ -16,16 +16,14 @@
"""
This module defines Actions that can be done and undone on a state
"""
+import gtk
+
from gettext import gettext as _
-from sugar.tutorius import gtkutils, addon
-from dialog import TutoriusDialog
-import overlayer
-from sugar.tutorius.editor import WidgetIdentifier
+from sugar.tutorius import addon
from sugar.tutorius.services import ObjectStore
from sugar.tutorius.properties import *
from sugar.graphics import icon
-import gtk.gdk
class DragWrapper(object):
"""Wrapper to allow gtk widgets to be dragged around"""
@@ -176,149 +174,4 @@ class Action(TPropContainer):
x, y = self._drag.position
self.position = [int(x), int(y)]
self.__edit_img.destroy()
-
-class OnceWrapper(Action):
- """
- Wraps a class to perform an action once only
-
- This ConcreteActions's do() method will only be called on the first do()
- and the undo() will be callable after do() has been called
- """
-
- _action = TAddonProperty()
-
- def __init__(self, action):
- Action.__init__(self)
- self._called = False
- self._need_undo = False
- self._action = action
-
- def do(self):
- """
- Do the action only on the first time
- """
- if not self._called:
- self._called = True
- self._action.do()
- self._need_undo = True
-
- def undo(self):
- """
- Undo the action if it's been done
- """
- if self._need_undo:
- self._action.undo()
- self._need_undo = False
-
-class WidgetIdentifyAction(Action):
- def __init__(self):
- Action.__init__(self)
- self.activity = None
- self._dialog = None
-
- def do(self):
- os = ObjectStore()
- if os.activity:
- self.activity = os.activity
-
- self._dialog = WidgetIdentifier(self.activity)
- self._dialog.show()
-
-
- def undo(self):
- if self._dialog:
- self._dialog.destroy()
-
-class ChainAction(Action):
- """Utility class to allow executing actions in a specific order"""
- def __init__(self, *actions):
- """ChainAction(action1, ... ) builds a chain of actions"""
- Action.__init__(self)
- self._actions = actions
-
- def do(self,**kwargs):
- """do() each action in the chain"""
- for act in self._actions:
- act.do(**kwargs)
-
- def undo(self):
- """undo() each action in the chain, starting with the last"""
- for act in reversed(self._actions):
- act.undo()
-
-class DisableWidgetAction(Action):
- def __init__(self, target):
- """Constructor
- @param target target treeish
- """
- Action.__init__(self)
- self._target = target
- self._widget = None
-
- def do(self):
- """Action do"""
- os = ObjectStore()
- if os.activity:
- self._widget = gtkutils.find_widget(os.activity, self._target)
- if self._widget:
- self._widget.set_sensitive(False)
-
- def undo(self):
- """Action undo"""
- if self._widget:
- self._widget.set_sensitive(True)
-
-
-class TypeTextAction(Action):
- """
- Simulate a user typing text in a widget
- Work on any widget that implements a insert_text method
-
- @param widget The treehish representation of the widget
- @param text the text that is typed
- """
- def __init__(self, widget, text):
- Action.__init__(self)
-
- self._widget = widget
- self._text = text
-
- def do(self, **kwargs):
- """
- Type the text
- """
- widget = gtkutils.find_widget(ObjectStore().activity, self._widget)
- if hasattr(widget, "insert_text"):
- widget.insert_text(self._text, -1)
-
- def undo(self):
- """
- no undo
- """
- pass
-
-class ClickAction(Action):
- """
- Action that simulate a click on a widget
- Work on any widget that implements a clicked() method
-
- @param widget The threehish representation of the widget
- """
- def __init__(self, widget):
- Action.__init__(self)
- self._widget = widget
-
- def do(self):
- """
- click the widget
- """
- widget = gtkutils.find_widget(ObjectStore().activity, self._widget)
- if hasattr(widget, "clicked"):
- widget.clicked()
- def undo(self):
- """
- No undo
- """
- pass
-
diff --git a/tutorius/addon.py b/tutorius/addon.py
index 51791d1..7ac68f7 100644
--- a/tutorius/addon.py
+++ b/tutorius/addon.py
@@ -38,6 +38,9 @@ import logging
PREFIX = __name__+"s"
PATH = re.sub("addon\\.py[c]$", "", __file__)+"addons"
+TYPE_ACTION = 'action'
+TYPE_EVENT = 'event'
+
_cache = None
def _reload_addons():
@@ -47,16 +50,23 @@ def _reload_addons():
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:
- return _cache[name]['class'](*args, **kwargs)
+ 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
@@ -73,4 +83,13 @@ def get_addon_meta(name):
_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/tutorius/bundler.py b/tutorius/bundler.py
deleted file mode 100644
index 8808d93..0000000
--- a/tutorius/bundler.py
+++ /dev/null
@@ -1,558 +0,0 @@
-# Copyright (C) 2009, Tutorius.org
-# Copyright (C) 2009, Jean-Christophe Savard <savard.jean.christophe@gmail.com>
-#
-# 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 uuid
-import xml.dom.minidom
-
-from sugar.tutorius import addon
-from sugar.tutorius.core import Tutorial, State, FiniteStateMachine
-from sugar.tutorius.filters import *
-from sugar.tutorius.actions import *
-from ConfigParser import SafeConfigParser
-
-# 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 os.path.join(os.getenv("SUGAR_BUNDLE_PATH"),"data","tutorius","data")
-
-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_FILENAME = "meta.ini"
-TUTORIAL_FILENAME = "tutorial.xml"
-NODE_COMPONENT = "Component"
-
-class TutorialStore(object):
-
- def list_available_tutorials(self, activity_name, activity_vers):
- """
- Generate the list of all tutorials present on disk for a
- given activity.
-
- @returns a map of tutorial {names : GUID}.
- """
- # check both under the activity data and user installed folders
- paths = [_get_store_root(), _get_bundle_root()]
-
- tutoGuidName = {}
-
- for repository in paths:
- # (our) convention dictates that tutorial folders are named
- # with their GUID (for unicity) but this is not enforced.
- try:
- for tuto in os.listdir(repository):
- parser = SafeConfigParser()
- parser.read(os.path.join(repository, tuto, INI_FILENAME))
- 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.lower() in activities:
- version = parser.get(INI_ACTIVITY_SECTION, activity_name)
- if activity_vers == version:
- tutoGuidName[guid] = name
- except OSError:
- # the repository may not exist. Continue scanning
- pass
-
- return tutoGuidName
-
- def load_tutorial(self, Guid):
- """
- Rebuilds a tutorial object from it's serialized state.
- Common storing paths will be scanned.
-
- @param Guid the generic identifier of the tutorial
- @returns a Tutorial object containing an FSM
- """
- bundle = TutorialBundler(Guid)
- bundle_path = bundle.get_tutorial_path()
- 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)
-
- tuto = Tutorial(name, fsm)
- return tuto
-
-
-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.
- """
- NotImplementedError
-
- def load_fsm(self):
- """
- Load fsm from disk.
- """
- 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_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":
- for subval in propval:
- compNode.appendChild(self._create_component_node(subval, doc))
- elif getattr(type(comp), propname).type == "addonlist":
- compNode.appendChild(self._create_component_node(subval, 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 a event filters.
- """
- eventFiltersList = doc.createElement("EventFiltersList")
- for event_f in event_filters:
- eventFilterNode = self._create_component_node(event_f, doc)
- 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 _find_tutorial_dir_with_guid(self, 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(), guid)
- # Then in the activty's bundle path
- activity_dir = os.path.join(_get_bundle_root(), guid)
-
- # 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 _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
- """
- reformed_event_filters_list = []
- event_filter_element_list = filters_elem.getElementsByTagName(NODE_COMPONENT)
- new_event_filter = None
-
- for event_filter in event_filter_element_list:
- new_event_filter = self._load_xml_component(event_filter)
-
- if new_event_filter is not None:
- reformed_event_filters_list.append(new_event_filter)
-
- return reformed_event_filters_list
-
- 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
- """
- new_action = addon.create(node.getAttribute("Class"))
- if not new_action:
- return None
-
- for attrib in node.attributes.keys():
- if attrib == "Class": continue
- # security note: keep sandboxed
- setattr(new_action, attrib, eval(node.getAttribute(attrib), {}, {}))
-
- # recreate complex attributes
- for sub in node.childNodes:
- name = getattr(new_action, sub.nodeName)
- if name == "addon":
- setattr(new_action, sub.getAttribute("Name"), self._load_xml_action(sub))
-
- 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 = actions_elem.getElementsByTagName(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 <States> 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 in events:
- fsm.add_event_filter(event)
-
- return fsm
-
-
- def load_fsm(self, guid):
- """
- Load fsm from xml file whose .ini file guid match argument guid.
- """
- # Fetch the directory (if any)
- tutorial_dir = self._find_tutorial_dir_with_guid(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):
- """
- 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())
-
- #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(), generated_guid, INI_FILENAME)
- if os.path.isfile(store_path):
- self.Path = os.path.dirname(store_path)
- else:
- #Bundle store
- bundle_path = os.path.join(_get_bundle_root(), 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:
- #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)
- cfg.set(INI_ACTIVITY_SECTION, os.environ['SUGAR_BUNDLE_NAME'],
- os.environ['SUGAR_BUNDLE_VERSION'])
-
- #Write the ini file
- cfg.write( file( os.path.join(self.Path, INI_FILENAME), 'w' ) )
-
- def get_tutorial_path(self):
- """
- Return the path of the .ini file associated with the guiven guid set in
- the Guid property of the Tutorial_Bundler. If the guid is present in
- more than one path, the store_root is given priority.
- """
-
- store_root = _get_store_root()
- bundle_root = _get_bundle_root()
-
- config = SafeConfigParser()
- path = None
-
- logging.debug("************ Path of store_root folder of activity : " \
- + store_root)
-
- # iterate in each GUID subfolder
- for dir in os.listdir(store_root):
-
- # iterate for each .ini file in the store_root folder
-
- for file_name in os.listdir(os.path.join(store_root, dir)):
- if file_name.endswith(".ini"):
- logging.debug("******************* Found .ini file : " \
- + file_name)
- config.read(os.path.join(store_root, dir, file_name))
- if config.get(INI_METADATA_SECTION, INI_GUID_PROPERTY) == self.Guid:
- xml_filename = config.get(INI_METADATA_SECTION,
- INI_XML_FSM_PROPERTY)
-
- path = os.path.join(store_root, dir)
- return path
-
- logging.debug("************ Path of bundle_root folder of activity : " \
- + bundle_root)
-
-
- # iterate in each GUID subfolder
- for dir in os.listdir(bundle_root):
-
- # iterate for each .ini file in the bundle_root folder
- for file_name in os.listdir(os.path.join(bundle_root, dir)):
- if file_name.endswith(".ini"):
- logging.debug("******************* Found .ini file : " \
- + file_name)
- config.read(os.path.join(bundle_root, dir, file_name))
- if config.get(INI_METADATA_SECTION, INI_GUID_PROPERTY) == self.Guid:
- path = os.path.join(bundle_root, self.Guid)
- return path
-
- if path is None:
- logging.debug("**************** Error : GUID not found")
- raise KeyError
-
- 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)
-
-
- def add_resources(self, typename, file):
- """
- Add ressources to metadata.
- """
- raise NotImplementedError("add_resources not implemented")
diff --git a/tutorius/constraints.py b/tutorius/constraints.py
index 36abdfb..e91f23a 100644
--- a/tutorius/constraints.py
+++ b/tutorius/constraints.py
@@ -200,7 +200,10 @@ class FileConstraint(Constraint):
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/tutorius/core.py b/tutorius/core.py
index dd2435e..d08c136 100644
--- a/tutorius/core.py
+++ b/tutorius/core.py
@@ -21,14 +21,12 @@ This module contains the core classes for tutorius
"""
-import gtk
import logging
-import copy
import os
-from sugar.tutorius.dialog import TutoriusDialog
-from sugar.tutorius.gtkutils import find_widget
-from sugar.tutorius.services import ObjectStore
+from sugar.tutorius.TProbe import ProbeManager
+from sugar.tutorius.dbustools import save_args
+from sugar.tutorius import addon
logger = logging.getLogger("tutorius")
@@ -36,8 +34,11 @@ 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):
+ def __init__(self, name, fsm, filename=None):
"""
Creates an unattached tutorial.
"""
@@ -51,21 +52,22 @@ class Tutorial (object):
self.state = None
self.handlers = []
- self.activity = None
+ self._probeMgr = ProbeManager()
+ self._activity_id = None
#Rest of initialisation happens when attached
- def attach(self, activity):
+ def attach(self, activity_id):
"""
Attach to a running activity
- @param activity the activity to attach to
+ @param activity_id the id of the activity to attach to
"""
#For now, absolutely detach if a previous one!
- if self.activity:
+ if self._activity_id:
self.detach()
- self.activity = activity
- ObjectStore().activity = activity
- ObjectStore().tutorial = self
+ self._activity_id = activity_id
+ self._probeMgr.attach(activity_id)
+ self._probeMgr.currentActivity = activity_id
self._prepare_activity()
self.state_machine.set_state("INIT")
@@ -77,9 +79,9 @@ class Tutorial (object):
# Uninstall the whole FSM
self.state_machine.teardown()
- #FIXME There should be some amount of resetting done here...
- self.activity = None
-
+ if not self._activity_id is None:
+ self._probeMgr.detach(self._activity_id)
+ self._activity_id = None
def set_state(self, name):
"""
@@ -89,18 +91,6 @@ class Tutorial (object):
self.state_machine.set_state(name)
-
- # Currently unused -- equivalent function is in each state
- def _eventfilter_state_done(self, eventfilter):
- """
- Callback handler for eventfilter to notify
- when we must go to the next state.
- """
- #XXX Tests should be run here normally
-
- #Swith to the next state pointed by the eventfilter
- self.set_state(eventfilter.get_next_state())
-
def _prepare_activity(self):
"""
Prepare the activity for the tutorial by loading the saved state and
@@ -112,9 +102,11 @@ class Tutorial (object):
#of the activity root directory
filename = os.getenv("SUGAR_ACTIVITY_ROOT") + "/data/" +\
self.activity_init_state_filename
- if os.path.exists(filename):
- self.activity.read_file(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):
"""
@@ -141,10 +133,9 @@ class State(object):
self._actions = action_list or []
- # Unused for now
- #self.tests = []
+ self._transitions= dict(event_filter_list or [])
- self._event_filters = event_filter_list or []
+ self._installedEvents = set()
self.tutorial = tutorial
@@ -168,12 +159,11 @@ class State(object):
Install the state itself, by first registering the event filters
and then triggering the actions.
"""
- for eventfilter in self._event_filters:
- eventfilter.install_handlers(self._event_filter_state_done_cb,
- activity=self.tutorial.activity)
+ 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:
- action.do()
+ self.tutorial.probeManager.install(action)
def teardown(self):
"""
@@ -182,38 +172,37 @@ class State(object):
removing dialogs that were displayed, removing highlights, etc...
"""
# Remove the handlers for the all of the state's event filters
- for event_filter in self._event_filters:
- event_filter.remove_handlers()
+ 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:
- action.undo()
+ self.tutorial.probeManager.uninstall(action)
- def _event_filter_state_done_cb(self, event_filter):
+ 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 event_filter The event filter that was called
+ @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(event_filter.get_next_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 (only if it wasn't added before)
+ Adds an action to the state
@param new_action The new action to execute when in this state
@return True if added, False otherwise
"""
- if new_action not in self._actions:
- self._actions.append(new_action)
- return True
- return False
+ 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
@@ -229,19 +218,21 @@ class State(object):
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_filter):
+ 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_filter The new event filter that will trigger a transition
+ @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_filter not in self._event_filters:
- self._event_filters.append(event_filter)
+ if event not in self._transitions.keys():
+ self._transitions[event]=next_state
return True
return False
@@ -249,7 +240,7 @@ class State(object):
"""
@return The list of event filters associated with this state.
"""
- return self._event_filters
+ return self._transitions.items()
def clear_event_filters(self):
"""
@@ -257,7 +248,63 @@ class State(object):
was just cleared will become a sink and will be the end of the
tutorial.
"""
- self._event_filters = []
+ 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?
+ for event in self._transitions:
+ state_name = self._transitions[event]
+ found = False
+ # For every event filter in the other state, try to match it with
+ # the current filter. We just need to find one with the right
+ # properties and values.
+ for otherEvent in otherState._transitions:
+ other_state_name = otherState._transitions[otherEvent]
+ if event == otherEvent:
+ found = True
+ break
+ if found == False:
+ # We could not find the given event filter in the other state.
+ 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):
"""
@@ -349,7 +396,7 @@ class FiniteStateMachine(State):
self._fsm_setup_done = True
# Execute all the FSM level actions
for action in self.actions:
- action.do()
+ self.tutorial.probeManager.install(action)
# Then, we need to run the setup of the current state
self.current_state.setup()
@@ -414,7 +461,7 @@ class FiniteStateMachine(State):
self._fsm_teardown_done = True
# Undo all the FSM level actions here
for action in self.actions:
- action.undo()
+ self.tutorial.probeManager.uninstall(action)
# TODO : It might be nice to have a start() and stop() method for the
# FSM.
@@ -470,9 +517,10 @@ class FiniteStateMachine(State):
#TODO : Move this code inside the State itself - we're breaking
# encap :P
- for event_filter in st._event_filters:
- if event_filter.get_next_state() == state_name:
- st._event_filters.remove(event_filter)
+ for event in st._transitions.keys():
+ state = st._transitions[event]
+ if state == state_name:
+ del st._transitions[event]
# Remove the state from the dictionary
del self._states[state_name]
@@ -490,8 +538,9 @@ class FiniteStateMachine(State):
next_states = set()
- for event_filter in state._event_filters:
- next_states.add(event_filter.get_next_state())
+ for event in state._transitions.keys():
+ state_name_in_dict = state._transitions[event]
+ next_states.add(state_name_in_dict)
return tuple(next_states)
@@ -513,9 +562,9 @@ class FiniteStateMachine(State):
states = []
# Walk through the list of states
for st in self._states.itervalues():
- for event_filter in st._event_filters:
- if event_filter.get_next_state() == state_name:
- states.append(event_filter.get_next_state())
+ for event, state in st._transitions.items():
+ if state == state_name:
+ states.append(state)
continue
return tuple(states)
@@ -526,3 +575,58 @@ class FiniteStateMachine(State):
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/tutorius/creator.py b/tutorius/creator.py
index 7455ecb..d5595e1 100644
--- a/tutorius/creator.py
+++ b/tutorius/creator.py
@@ -22,16 +22,20 @@ the activity itself.
# 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
-from sugar.graphics.toolbutton import ToolButton
+import os
+from sugar.graphics import icon
+import copy
from sugar.tutorius import overlayer, gtkutils, actions, bundler, properties, addon
-from sugar.tutorius import filters
+from sugar.tutorius import filters, __path__
from sugar.tutorius.services import ObjectStore
from sugar.tutorius.linear_creator import LinearCreator
-from sugar.tutorius.core import Tutorial
+from sugar.tutorius.core import Tutorial, FiniteStateMachine, State
+from sugar.tutorius import viewer
class Creator(object):
"""
@@ -47,80 +51,171 @@ class Creator(object):
"""
self._activity = activity
if not tutorial:
- self._tutorial = LinearCreator()
+ 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
- self._state_bubble = overlayer.TextBubble(self._tutorial.state_name)
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)
- self._activity._overlayer.put(self._state_bubble,
- self._width/2-self._state_bubble.allocation.width/2, 0)
-
dlg_width = 300
dlg_height = 70
sw = gtk.gdk.screen_width()
sh = gtk.gdk.screen_height()
- self._tooldialog = gtk.Window()
- self._tooldialog.set_title("Tutorius tools")
- self._tooldialog.set_transient_for(self._activity)
- self._tooldialog.set_decorated(True)
- self._tooldialog.set_resizable(False)
- self._tooldialog.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY)
- self._tooldialog.set_destroy_with_parent(True)
- self._tooldialog.set_deletable(False)
- self._tooldialog.set_size_request(dlg_width, dlg_height)
-
- toolbar = gtk.Toolbar()
- for tool in addon.list_addons():
- meta = addon.get_addon_meta(tool)
- toolitem = ToolButton(meta['icon'])
- toolitem.set_tooltip(meta['display_name'])
- toolitem.connect("clicked", self._add_action_cb, tool)
- toolbar.insert(toolitem, -1)
- toolitem = ToolButton("go-next")
- toolitem.connect("clicked", self._add_step_cb)
- toolitem.set_tooltip("Add Step")
- toolbar.insert(toolitem, -1)
- toolitem = ToolButton("stop")
- toolitem.connect("clicked", self._cleanup_cb)
- toolitem.set_tooltip("End Tutorial")
- toolbar.insert(toolitem, -1)
- self._tooldialog.add(toolbar)
- self._tooldialog.show_all()
- # simpoir: I suspect the realized widget is a tiny bit larger than
- # it should be, thus the -10.
- self._tooldialog.move(sw-10-dlg_width, sh-dlg_height)
-
- self._propedit = EditToolBox(self._activity)
-
- def _evfilt_cb(self, menuitem, event_name, *args):
+
+ 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 set_next_state(self, state, event, next_state):
+ # FIXME HACK
+ 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])
+ #ev_list[0].get_next_state())
+ 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])
+ #ev_list[0].get_next_state())
+ 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])
+ #ev_list[0].get_next_state())
+ 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()
+ # FIXME HACK START
+ self.set_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1])
+ #ev_list[0].set_next_state(
+ # next_state.get_event_filter_list()[0].get_next_state())
+ # FIXME HACK END
+ for ev, next_state in ev_list:
+ state.add_event_filter(ev, next_state)
+
+ self.set_insertion_point(ev_list[0][1])
+ #self.set_insertion_point(ev_list[0].get_next_state())
+ 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.
"""
- self.introspecting = False
- eventfilter = addon.create('GtkWidgetEventFilter',
- next_state=None,
- object_id=self._selected_widget,
- event_name=event_name)
# undo actions so they don't persist through step editing
- for action in self._tutorial.current_actions:
+ for action in self._state.get_action_list():
action.exit_editmode()
- self._tutorial.event(eventfilter)
- self._state_bubble.label = self._tutorial.state_name
self._hlmask.covered = None
self._propedit.action = None
self._activity.queue_draw()
@@ -159,63 +254,71 @@ class Creator(object):
self._eventmenu.popup(None, None, None, evt.button, evt.time)
self._activity.queue_draw()
- def set_intropecting(self, value):
- """
- Set whether creator is in UI introspection mode. Setting this will
- connect necessary handlers.
- @param value True to setup introspection handlers.
- """
- if bool(value) ^ bool(self._intro_mask):
- if value:
- self._intro_mask = overlayer.Mask(catch_events=True)
- self._intro_handle = self._intro_mask.connect_after(
- "button-press-event", self._intro_cb)
- self._activity._overlayer.put(self._intro_mask, 0, 0)
- else:
- self._intro_mask.catch_events = False
- self._intro_mask.disconnect(self._intro_handle)
- self._intro_handle = None
- self._activity._overlayer.remove(self._intro_mask)
- self._intro_mask = None
-
- def get_introspecting(self):
- """
- Whether creator is in UI introspection (catch all event) mode.
- @return True if introspection handlers are connected, or False if not.
- """
- return bool(self._intro_mask)
-
- introspecting = property(fset=set_intropecting, fget=get_introspecting)
-
- def _add_action_cb(self, widget, actiontype):
+ def _add_action_cb(self, widget, path):
"""Callback for the action creation toolbar tool"""
- action = addon.create(actiontype)
- if isinstance(action, actions.Action):
- action.enter_editmode()
- self._tutorial.action(action)
- # FIXME: replace following with event catching
- action._drag._eventbox.connect_after(
- "button-release-event", self._action_refresh_cb, action)
+ action_type = self._propedit._actions_icons[path][2]
+ 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_icons[path][2]
+ 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.TGtkSignal):
+ 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.set_next_state(self._state, event, new_state.name)
+ next_state = new_state.name
+ #event.set_next_state(new_state.name)
+ # blocks are shifted, full redraw is necessary
+ self._overview.win.queue_draw()
else:
- addonname = type(action).__name__
- meta = addon.get_addon_meta(addonname)
- had_introspect = False
- for propname in meta['mandatory_props']:
- prop = getattr(type(action), propname)
- if isinstance(prop, properties.TUAMProperty):
- had_introspect = True
- self.introspecting = True
- elif isinstance(prop, properties.TStringProperty):
- dlg = TextInputDialog(title="Mandatory property",
- field=propname)
- setattr(action, propname, dlg.pop())
- else:
- raise NotImplementedError()
-
- # FIXME: hack to reuse previous introspection code
- if not had_introspect:
- self._tutorial.event(action)
+ # append empty event only if edit not inserting between events
+ self.set_next_state(self._state, event, str(self._state_counter))
+ next_state = str(self._state_counter)
+ #event.set_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):
"""
@@ -230,44 +333,54 @@ class Creator(object):
"button-release-event", self._action_refresh_cb, action)
self._propedit.action = action
- def _add_step_cb(self, widget):
- """Callback for the "add step" tool"""
- self.introspecting = True
+ self._overview.win.queue_draw()
def _cleanup_cb(self, *args):
"""
Quit editing and cleanup interface artifacts.
"""
- self.introspecting = False
- eventfilter = filters.EventFilter(None)
# undo actions so they don't persist through step editing
- for action in self._tutorial.current_actions:
+ for action in self._state.get_action_list():
action.exit_editmode()
- self._tutorial.event(eventfilter)
- dlg = TextInputDialog(text=T("Enter a tutorial title."),
- field=T("Title"))
- tutorialName = ""
- while not tutorialName: tutorialName = dlg.pop()
- dlg.destroy()
-
- # prepare tutorial for serialization
- tuto = Tutorial(tutorialName, self._tutorial.fsm)
- bundle = bundler.TutorialBundler()
- bundle.write_metadata_file(tuto)
- bundle.write_fsm(self._tutorial.fsm)
+ 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._activity._overlayer.remove(self._state_bubble)
self._hlmask.destroy()
self._hlmask = None
- self._tooldialog.destroy()
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 = bundler.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.
@@ -277,46 +390,53 @@ class Creator(object):
activity._creator = Creator(activity)
launch = staticmethod(launch)
-class EditToolBox(gtk.Window):
- """Helper toolbox class for managing action properties"""
- def __init__(self, parent, action=None):
- """
- Create the property edition toolbox and display it.
+class ToolBox(object):
+ def __init__(self, parent):
+ super(ToolBox, self).__init__()
+ self.__parent = parent
+ glade_file = os.path.join(__path__[0], 'ui', 'creator.glade')
+ self.tree = gtk.glade.XML(glade_file)
+ self.window = self.tree.get_widget('mainwindow')
+ self._propbox = self.tree.get_widget('propbox')
- @param parent the parent window of this toolbox, usually an activity
- @param action the action to introspect/edit
- """
- gtk.Window.__init__(self)
- self._action = None
- self.__parent = parent # private avoid gtk clash
-
- self.set_title("Action Properties")
- self.set_transient_for(parent)
- self.set_decorated(True)
- self.set_resizable(False)
- self.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY)
- self.set_destroy_with_parent(True)
- self.set_deletable(False)
- self.set_size_request(200, 400)
-
- self._vbox = gtk.VBox()
- self.add(self._vbox)
- propwin = gtk.ScrolledWindow()
- propwin.props.hscrollbar_policy = gtk.POLICY_AUTOMATIC
- propwin.props.vscrollbar_policy = gtk.POLICY_AUTOMATIC
- self._vbox.pack_start(propwin)
- self._propbox = gtk.VBox(spacing=10)
- propwin.add(self._propbox)
-
- self.action = action
-
- sw = gtk.gdk.screen_width()
- sh = gtk.gdk.screen_height()
+ self.window.set_transient_for(parent)
- self.show_all()
- self.move(sw-10-200, (sh-400)/2)
-
- def refresh(self):
+ self._action = None
+ self._actions_icons = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str)
+ self._actions_icons.set_sort_column_id(0, gtk.SORT_ASCENDING)
+ self._events_icons = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str)
+ self._events_icons.set_sort_column_id(0, 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_icons.append((label, img, toolname, meta['display_name']))
+ else:
+ self._events_icons.append((label, img, toolname, meta['display_name']))
+
+ iconview1 = self.tree.get_widget('iconview1')
+ iconview1.set_model(self._actions_icons)
+ iconview1.set_text_column(0)
+ iconview1.set_pixbuf_column(1)
+ iconview1.set_tooltip_column(3)
+ iconview2 = self.tree.get_widget('iconview2')
+ iconview2.set_model(self._events_icons)
+ iconview2.set_text_column(0)
+ iconview2.set_pixbuf_column(1)
+ iconview2.set_tooltip_column(3)
+
+ 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
@@ -344,12 +464,10 @@ class EditToolBox(gtk.Window):
def set_action(self, action):
"""Setter for the action property."""
if self._action is action:
- self.refresh()
+ self.refresh_properties()
return
- parent = self._propbox.get_parent()
- parent.remove(self._propbox)
- self._propbox = gtk.VBox(spacing=10)
- parent.add(self._propbox)
+ for old_prop in self._propbox.get_children():
+ self._propbox.remove(old_prop)
self._action = action
if action is None:
@@ -384,8 +502,8 @@ class EditToolBox(gtk.Window):
propwdg.set_text(str(propval))
row.pack_end(propwdg)
self._propbox.pack_start(row, expand=False)
- self._vbox.show_all()
- self.refresh()
+ self._propbox.show_all()
+ self.refresh_properties()
def get_action(self):
"""Getter for the action property"""
@@ -395,7 +513,10 @@ class EditToolBox(gtk.Window):
def _list_prop_changed(self, widget, evt, action, propname, idx):
try:
- getattr(action, propname)[idx] = int(widget.get_text())
+ #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)
@@ -407,9 +528,143 @@ class EditToolBox(gtk.Window):
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, text, field):
- gtk.MessageDialog.__init__(self, None,
+ 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,
@@ -433,4 +688,35 @@ class TextInputDialog(gtk.MessageDialog):
def _dialog_done_cb(self, entry, response):
self.response(response)
+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/tutorius/dbustools.py b/tutorius/dbustools.py
new file mode 100644
index 0000000..1b685d7
--- /dev/null
+++ b/tutorius/dbustools.py
@@ -0,0 +1,41 @@
+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 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/tutorius/engine.py b/tutorius/engine.py
new file mode 100644
index 0000000..dda9f3f
--- /dev/null
+++ b/tutorius/engine.py
@@ -0,0 +1,48 @@
+import logging
+import dbus.mainloop.glib
+from jarabe.model import shell
+
+from sugar.tutorius.bundler import TutorialStore
+from sugar.bundle.activitybundle import ActivityBundle
+
+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
+
+ store = TutorialStore()
+
+ #Get the active activity from the shell
+ activity = self._shell.get_active_activity()
+ self._tutorial = store.load_tutorial(tutorialID, bundle_path=activity.get_bundle_path())
+
+ #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/tutorius/filters.py b/tutorius/filters.py
index aa8c997..44621d5 100644
--- a/tutorius/filters.py
+++ b/tutorius/filters.py
@@ -15,13 +15,9 @@
# 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 logging
logger = logging.getLogger("filters")
-from sugar.tutorius.gtkutils import find_widget
-from sugar.tutorius.services import ObjectStore
from sugar.tutorius import properties
@@ -30,31 +26,13 @@ class EventFilter(properties.TPropContainer):
Base class for an event filter
"""
- next_state = properties.TStringProperty("None")
-
- def __init__(self, next_state=None):
+ def __init__(self):
"""
Constructor.
- @param next_state name of the next state
"""
super(EventFilter, self).__init__()
- if next_state:
- self.next_state = next_state
self._callback = None
- def get_next_state(self):
- """
- Getter for the next state
- """
- return self.next_state
-
- def set_next_state(self, new_next_name):
- """
- Setter for the next state. Should only be used during construction of
- the event_fitler, not while the tutorial is running.
- """
- self.next_state = new_next_name
-
def install_handlers(self, callback, **kwargs):
"""
install_handlers is called for eventfilters to setup all
@@ -94,111 +72,3 @@ class EventFilter(properties.TPropContainer):
if self._callback:
self._callback(self)
-class TimerEvent(EventFilter):
- """
- TimerEvent is a special EventFilter that uses gobject
- timeouts to trigger a state change after a specified amount
- of time. It must be used inside a gobject main loop to work.
- """
- def __init__(self,next_state,timeout_s):
- """Constructor.
-
- @param next_state default EventFilter param, passed on to EventFilter
- @param timeout_s timeout in seconds
- """
- super(TimerEvent,self).__init__(next_state)
- self._timeout = timeout_s
- self._handler_id = None
-
- def install_handlers(self, callback, **kwargs):
- """install_handlers creates the timer and starts it"""
- super(TimerEvent,self).install_handlers(callback, **kwargs)
- #Create the timer
- self._handler_id = gobject.timeout_add_seconds(self._timeout, self._timeout_cb)
-
- def remove_handlers(self):
- """remove handler removes the timer"""
- super(TimerEvent,self).remove_handlers()
- if self._handler_id:
- try:
- #XXX What happens if this was already triggered?
- #remove the timer
- gobject.source_remove(self._handler_id)
- except:
- pass
-
- def _timeout_cb(self):
- """
- _timeout_cb triggers the eventfilter callback.
-
- It is necessary because gobject timers only stop if the callback they
- trigger returns False
- """
- self.do_callback()
- return False #Stops timeout
-
-class GtkWidgetTypeFilter(EventFilter):
- """
- Event Filter that listens for keystrokes on a widget
- """
- def __init__(self, next_state, object_id, text=None, strokes=None):
- """Constructor
- @param next_state default EventFilter param, passed on to EventFilter
- @param object_id object tree-ish identifier
- @param text resulting text expected
- @param strokes list of strokes expected
-
- At least one of text or strokes must be supplied
- """
- super(GtkWidgetTypeFilter, self).__init__(next_state)
- self._object_id = object_id
- self._text = text
- self._captext = ""
- self._strokes = strokes
- self._capstrokes = []
- self._widget = None
- self._handler_id = None
-
- def install_handlers(self, callback, **kwargs):
- """install handlers
- @param callback default EventFilter callback arg
- """
- super(GtkWidgetTypeFilter, self).install_handlers(callback, **kwargs)
- logger.debug("~~~GtkWidgetTypeFilter install")
- activity = ObjectStore().activity
- if activity is None:
- logger.error("No activity")
- raise RuntimeWarning("no activity in the objectstore")
-
- self._widget = find_widget(activity, self._object_id)
- if self._widget:
- self._handler_id= self._widget.connect("key-press-event",self.__keypress_cb)
- logger.debug("~~~Connected handler %d on %s" % (self._handler_id,self._object_id) )
-
- def remove_handlers(self):
- """remove handlers"""
- super(GtkWidgetTypeFilter, self).remove_handlers()
- #if an event was connected, disconnect it
- if self._handler_id:
- self._widget.handler_disconnect(self._handler_id)
- self._handler_id=None
-
- def __keypress_cb(self, widget, event, *args):
- """keypress callback"""
- logger.debug("~~~keypressed!")
- key = event.keyval
- keystr = event.string
- logger.debug("~~~Got key: " + str(key) + ":"+ keystr)
- self._capstrokes += [key]
- #TODO Treat other stuff, such as arrows
- if key == gtk.keysyms.BackSpace:
- self._captext = self._captext[:-1]
- else:
- self._captext = self._captext + keystr
-
- logger.debug("~~~Current state: " + str(self._capstrokes) + ":" + str(self._captext))
- if not self._strokes is None and self._strokes in self._capstrokes:
- self.do_callback()
- if not self._text is None and self._text in self._captext:
- self.do_callback()
-
diff --git a/tutorius/linear_creator.py b/tutorius/linear_creator.py
index 91b11f4..78e94ce 100644
--- a/tutorius/linear_creator.py
+++ b/tutorius/linear_creator.py
@@ -58,9 +58,8 @@ class LinearCreator(object):
# 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)
- event_filter.set_next_state(next_state_name)
state = State(self.state_name, action_list=self.current_actions,
- event_filter_list=[event_filter])
+ event_filter_list=[(event_filter, next_state_name),])
self.state_name = next_state_name
self.nb_state += 1
diff --git a/tutorius/overlayer.py b/tutorius/overlayer.py
index 931949d..b967739 100644
--- a/tutorius/overlayer.py
+++ b/tutorius/overlayer.py
@@ -58,13 +58,13 @@ class Overlayer(gtk.Layout):
@param overlayed widget to be overlayed. Will be resized to full size.
"""
def __init__(self, overlayed=None):
- gtk.Layout.__init__(self)
+ super(Overlayer, self).__init__()
self._overlayed = overlayed
if overlayed:
self.put(overlayed, 0, 0)
- self.__realizer = self.connect("expose-event", self.__init_realized)
+ self.__realizer = self.connect_after("realize", self.__init_realized)
self.connect("size-allocate", self.__size_allocate)
self.show()
@@ -83,13 +83,13 @@ class Overlayer(gtk.Layout):
if hasattr(child, "draw_with_context"):
# if the widget has the CanvasDrawable protocol, use it.
child.no_expose = True
- gtk.Layout.put(self, child, x, y)
+ 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, event):
+ 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
@@ -157,7 +157,7 @@ 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]):
+ def __init__(self, text, speaker=None, tailpos=(0,0)):
"""
Creates a new cairo rendered text bubble.
@@ -199,7 +199,7 @@ class TextBubble(gtk.Widget):
# TODO fetch speaker coordinates
# draw bubble tail if present
- if self.tailpos != [0,0]:
+ 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)
@@ -228,7 +228,7 @@ class TextBubble(gtk.Widget):
context.fill()
# bubble painting. Redrawing the inside after the tail will combine
- if self.tailpos != [0,0]:
+ 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)
diff --git a/tutorius/properties.py b/tutorius/properties.py
index abf76e5..e3693fc 100644
--- a/tutorius/properties.py
+++ b/tutorius/properties.py
@@ -95,6 +95,25 @@ class TPropContainer(object):
"""
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 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 :
@@ -145,19 +164,6 @@ class TAddonListProperty(TutoriusProperty):
"""
pass
-
- 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
-
class TIntProperty(TutoriusProperty):
"""
Represents an integer. Can have an upper value limit and/or a lower value
@@ -207,8 +213,20 @@ class TArrayProperty(TutoriusProperty):
self.type = "array"
self.max_size_limit = MaxSizeConstraint(max_size_limit)
self.min_size_limit = MinSizeConstraint(min_size_limit)
- self.default = self.validate(value)
+ 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.
@@ -287,8 +305,10 @@ class TUAMProperty(TutoriusProperty):
"""
Represents a widget of the interface by storing its UAM.
"""
- # TODO : Pending UAM check-in (LP 355199)
- pass
+ def __init__(self, value=None):
+ TutoriusProperty.__init__(self)
+
+ self.type = "uam"
class TAddonProperty(TutoriusProperty):
"""
@@ -311,14 +331,31 @@ class TAddonProperty(TutoriusProperty):
return super(TAddonProperty, self).validate(value)
raise ValueError("Expected TPropContainer instance as TaddonProperty value")
+class TGtkSignal(TutoriusProperty):
+ """
+ Represents a gobject signal for a GTK widget.
+ """
+ def __init__(self, value):
+ TutoriusProperty.__init__(self)
+ self.type = "gtk-signal"
+
+ self.default = self.validate(value)
+
class TAddonListProperty(TutoriusProperty):
"""
Reprensents an embedded tutorius Addon List Component.
See TAddonProperty
"""
def __init__(self):
- super(TAddonProperty, self).__init__()
+ 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/tutorius/service.py b/tutorius/service.py
new file mode 100644
index 0000000..21f0cf1
--- /dev/null
+++ b/tutorius/service.py
@@ -0,0 +1,85 @@
+from engine import Engine
+import dbus
+
+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/tutorius/services.py b/tutorius/services.py
index 9ed2e50..e7b17d8 100644
--- a/tutorius/services.py
+++ b/tutorius/services.py
@@ -22,6 +22,9 @@ 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.
"""
diff --git a/tutorius/store.py b/tutorius/store.py
new file mode 100644
index 0000000..480c81b
--- /dev/null
+++ b/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/tutorius/uam/__init__.py b/tutorius/uam/__init__.py
index 7cf5671..bcd67e1 100644
--- a/tutorius/uam/__init__.py
+++ b/tutorius/uam/__init__.py
@@ -65,7 +65,8 @@ for subscheme in [".".join([SCHEME,s]) for s in __parsers]:
class SchemeError(Exception):
def __init__(self, message):
Exception.__init__(self, message)
- self.message = message
+ ## Commenting this line as it is causing an error in the tests
+ ##self.message = message
def parse_uri(uri):
diff --git a/tutorius/ui/creator.glade b/tutorius/ui/creator.glade
new file mode 100644
index 0000000..1c9669d
--- /dev/null
+++ b/tutorius/ui/creator.glade
@@ -0,0 +1,209 @@
+<?xml version="1.0"?>
+<glade-interface>
+ <!-- interface-requires gtk+ 2.16 -->
+ <!-- interface-naming-policy project-wide -->
+ <widget class="GtkWindow" id="mainwindow">
+ <property name="width_request">300</property>
+ <property name="height_request">500</property>
+ <property name="title" translatable="yes">Toolbox</property>
+ <property name="resizable">False</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">200</property>
+ <property name="default_height">500</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="skip_taskbar_hint">True</property>
+ <property name="skip_pager_hint">True</property>
+ <property name="focus_on_map">False</property>
+ <property name="deletable">False</property>
+ <signal name="destroy" handler="on_mainwindow_destroy"/>
+ <child>
+ <widget class="GtkVBox" id="vbox1">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">5</property>
+ <child>
+ <widget class="GtkHButtonBox" id="hbuttonbox1">
+ <property name="visible">True</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">start</property>
+ <child>
+ <widget class="GtkButton" id="button2">
+ <property name="label">gtk-save</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="on_save_clicked"/>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkButton" id="button4">
+ <property name="label">gtk-quit</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="on_quit_clicked"/>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkScrolledWindow" id="scrolledwindow1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">never</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <property name="shadow_type">in</property>
+ <child>
+ <widget class="GtkViewport" id="viewport1">
+ <property name="visible">True</property>
+ <property name="resize_mode">queue</property>
+ <child>
+ <widget class="GtkVBox" id="vbox2">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <widget class="GtkExpander" id="expander1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <widget class="GtkIconView" id="iconview1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="columns">2</property>
+ <property name="row_spacing">0</property>
+ <property name="column_spacing">0</property>
+ <property name="item_padding">0</property>
+ <signal name="item_activated" handler="on_action_activate"/>
+ </widget>
+ </child>
+ <child>
+ <widget class="GtkLabel" id="label3">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">actions</property>
+ </widget>
+ <packing>
+ <property name="type">label_item</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkExpander" id="expander2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <widget class="GtkIconView" id="iconview2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="columns">2</property>
+ <property name="row_spacing">0</property>
+ <property name="column_spacing">0</property>
+ <property name="item_padding">0</property>
+ <signal name="item_activated" handler="on_event_activate"/>
+ </widget>
+ </child>
+ <child>
+ <widget class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">events</property>
+ </widget>
+ <packing>
+ <property name="type">label_item</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ </child>
+ </widget>
+ </child>
+ </widget>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkVBox" id="propbox">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">10</property>
+ <child>
+ <placeholder/>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="padding">5</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkHButtonBox" id="hbuttonbox2">
+ <property name="visible">True</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">start</property>
+ <child>
+ <widget class="GtkButton" id="button1">
+ <property name="label">gtk-media-record</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkButton" id="button3">
+ <property name="label">gtk-media-stop</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ </widget>
+ </child>
+ </widget>
+</glade-interface>
diff --git a/tutorius/vault.py b/tutorius/vault.py
new file mode 100644
index 0000000..9215e8d
--- /dev/null
+++ b/tutorius/vault.py
@@ -0,0 +1,861 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Jean-Christophe Savard <savard.jean.christophe@gmail.com>
+#
+# 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 sugar.tutorius import addon
+from sugar.tutorius.core import Tutorial, State, FiniteStateMachine
+from ConfigParser import SafeConfigParser
+
+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.
+ <Component Class="OnceWrapper">
+ <property name="addon">
+ <Component Class="BubbleMessage" message="'Hi!'" position="[12,32]"/>
+ </property>
+ </Component>
+
+ 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.
+ <Component Class="ChainAction">
+ <listproperty name="actions">
+ <Component Class="BubbleMessage" message="'Hi!'" position="[15,35]"/>
+ <Component Class="DialogMessage" message="'Multi-action!'" position="[45,10]"/>
+ </listproperty>
+ </Component>
+
+ 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 <States> 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)
+
+
+ @staticmethod
+ def write_fsm(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/tutorius/viewer.py b/tutorius/viewer.py
new file mode 100644
index 0000000..751e89a
--- /dev/null
+++ b/tutorius/viewer.py
@@ -0,0 +1,406 @@
+# 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.VBox()
+ vbox = gtk.ScrolledWindow()
+ self.win.add(vbox)
+
+ canvas = gtk.DrawingArea()
+ #vbox.pack_start(canvas)
+ vbox.add_with_viewport(canvas) # temp
+ 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.scroll = gtk.HScrollbar()
+ #vbox.pack_end(self.scroll, False)
+
+ 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_WIDTH<origin[0] and \
+ self.drag_pos[0]>origin[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_WIDTH<origin[0] and \
+ self.drag_pos[0]>origin[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]<origin[0]:
+ new_insert_point = state
+
+ if event_list:
+ try:
+ state = states[cur_state]
+ except KeyError:
+ break
+ yield True
+ else:
+ break
+
+ ctx.set_matrix(app_start)
+ self._render_app_hints(ctx, cur_app)
+
+ if self.click_pos:
+ if not new_insert_point:
+ new_insert_point = state
+
+ self._creator.set_insertion_point(new_insert_point.name)
+
+ yield False
+
+ def _render_snapshot(self, ctx, elem):
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.5)
+ ctx.rectangle(0, 0, SNAP_WIDTH, SNAP_HEIGHT)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ if hasattr(elem, 'position'):
+ pos = elem.position
+ # FIXME this size approximation is fine, but I believe we could
+ # do better.
+ ctx.scale(SNAP_SCALE, SNAP_SCALE)
+ ctx.rectangle(pos[0], pos[1], ACTION_WIDTH, ACTION_HEIGHT)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ def _render_app_hints(self, ctx, appname):
+ ctx.set_source_rgb(0.0, 0.0, 0.0)
+ ctx.set_dash((1,1,0,0), 1)
+ ctx.move_to(0, 0)
+ ctx.line_to(0, self.alloc.height)
+ ctx.stroke()
+ ctx.set_dash(tuple(), 1)
+
+ bundle_path = os.getenv("SUGAR_BUNDLE_PATH")
+ if bundle_path:
+ icon_path = activitybundle.ActivityBundle(bundle_path).get_icon()
+ icon = rsvg.Handle(icon_path)
+ ctx.save()
+ ctx.translate(-15, 0)
+ ctx.scale(0.5, 0.5)
+ icon_surf = icon.render_cairo(ctx)
+ ctx.restore()
+
+
+ def render_action(self, ctx, width, height, action):
+ ctx.save()
+ inner_width = width-(BLOCK_CORNERS<<1)
+ inner_height = height-(BLOCK_CORNERS<<1)
+
+ paint_border = ctx.rel_line_to
+ filling = cairo.LinearGradient(0, 0, 0, inner_height)
+ if action not in self.selection:
+ filling.add_color_stop_rgb(0.0, 0.7, 0.7, 1.0)
+ filling.add_color_stop_rgb(1.0, 0.1, 0.1, 0.8)
+ else:
+ filling.add_color_stop_rgb(0.0, 0.4, 0.4, 0.8)
+ filling.add_color_stop_rgb(1.0, 0.0, 0.0, 0.5)
+ tracing = cairo.LinearGradient(0, 0, 0, inner_height)
+ tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0)
+ tracing.add_color_stop_rgb(1.0, 0.2, 0.2, 0.2)
+
+ ctx.move_to(BLOCK_CORNERS, 0)
+ paint_border(inner_width, 0)
+ ctx.arc(inner_width+BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI2, 0.0)
+ ctx.arc(inner_width+BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, 0.0, PI2)
+ ctx.arc(BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, PI2, PI)
+ ctx.arc(BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI, -PI2)
+
+ ctx.set_source(tracing)
+ ctx.stroke_preserve()
+ ctx.set_source(filling)
+ ctx.fill()
+
+ addon_name = addon.get_name_from_type(type(action))
+ # TODO use icon pool
+ icon_name = addon.get_addon_meta(addon_name)['icon']
+ rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name))
+ ctx.save()
+ ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD)
+ ctx.scale(0.5, 0.5)
+ icon_surf = rsvg_icon.render_cairo(ctx)
+
+ ctx.restore()
+
+ ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2)
+ self._render_snapshot(ctx, action)
+
+ ctx.restore()
+
+ def render_event(self, ctx, width, height, event):
+ ctx.save()
+ inner_width = width-(BLOCK_CORNERS<<1)
+ inner_height = height-(BLOCK_CORNERS<<1)
+
+ filling = cairo.LinearGradient(0, 0, 0, inner_height)
+ if event not in self.selection:
+ filling.add_color_stop_rgb(0.0, 1.0, 0.8, 0.6)
+ filling.add_color_stop_rgb(1.0, 1.0, 0.6, 0.2)
+ else:
+ filling.add_color_stop_rgb(0.0, 0.8, 0.6, 0.4)
+ filling.add_color_stop_rgb(1.0, 0.6, 0.4, 0.1)
+ tracing = cairo.LinearGradient(0, 0, 0, inner_height)
+ tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0)
+ tracing.add_color_stop_rgb(1.0, 0.3, 0.3, 0.3)
+
+ ctx.move_to(BLOCK_CORNERS, 0)
+ ctx.rel_line_to(inner_width, 0)
+ ctx.rel_line_to(BLOCK_CORNERS, BLOCK_CORNERS)
+ ctx.rel_line_to(0, inner_height)
+ ctx.rel_line_to(-BLOCK_CORNERS, BLOCK_CORNERS)
+ ctx.rel_line_to(-inner_width, 0)
+ ctx.rel_line_to(-BLOCK_CORNERS, -BLOCK_CORNERS)
+ ctx.rel_line_to(0, -inner_height)
+ ctx.close_path()
+
+ ctx.set_source(tracing)
+ ctx.stroke_preserve()
+ ctx.set_source(filling)
+ ctx.fill()
+
+ addon_name = addon.get_name_from_type(type(event))
+ # TODO use icon pool
+ icon_name = addon.get_addon_meta(addon_name)['icon']
+ rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name))
+ ctx.save()
+ ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD)
+ ctx.scale(0.5, 0.5)
+ icon_surf = rsvg_icon.render_cairo(ctx)
+
+ ctx.restore()
+
+ ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2)
+ self._render_snapshot(ctx, event)
+
+ ctx.restore()
+
+ def on_viewer_expose(self, widget, evt, states):
+ ctx = widget.window.cairo_create()
+ self.alloc = widget.get_allocation()
+ ctx.set_source_pixmap(widget.window,
+ widget.allocation.x,
+ widget.allocation.y)
+
+ #draw no more than our expose event intersects our child
+ region = gtk.gdk.region_rectangle(widget.allocation)
+ r = gtk.gdk.region_rectangle(evt.area)
+ region.intersect(r)
+ ctx.region (region)
+ ctx.clip()
+ ctx.paint()
+
+ ctx.translate(BLOCK_PADDING, BLOCK_PADDING)
+
+ painter = self._paint_state(ctx, states)
+ while painter.next(): pass
+
+ if self.click_pos and self.drag_pos:
+ ctx.set_matrix(cairo.Matrix())
+ ctx.rectangle(self.click_pos[0], self.click_pos[1],
+ self.drag_pos[0]-self.click_pos[0],
+ self.drag_pos[1]-self.click_pos[1])
+ ctx.set_source_rgba(0, 0, 1, 0.5)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ return False
+
+ def _on_click(self, widget, evt):
+ # the rendering pipeline will work out the click validation process
+ self.drag_pos = None
+ self.drag_pos = self.click_pos = evt.get_coords()
+ widget.queue_draw()
+
+ self.selection = []
+
+ def _on_drag(self, widget, evt):
+ self.drag_pos = evt.get_coords()
+ widget.queue_draw()
+
+ def _on_drag_end(self, widget, evt):
+ self.click_pos = self.drag_pos = None
+ widget.queue_draw()
+
+ def _on_key_press(self, widget, evt):
+ if evt.keyval == gtk.keysyms.BackSpace:
+ # remove selection
+ for selected in self.selection:
+ if isinstance(selected, EventFilter):
+ self._creator.delete_state()
+ else:
+ self._creator.delete_action(selected)
+ widget.queue_draw()
+
+