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/Makefile.am19
-rw-r--r--tutorius/__init__.py0
-rw-r--r--tutorius/actions.py324
-rw-r--r--tutorius/addon.py76
-rw-r--r--tutorius/bundler.py556
-rw-r--r--tutorius/constraints.py207
-rw-r--r--tutorius/core.py528
-rw-r--r--tutorius/creator.py436
-rw-r--r--tutorius/dialog.py59
-rw-r--r--tutorius/editor.py318
-rw-r--r--tutorius/filters.py204
-rw-r--r--tutorius/gtkutils.py203
-rw-r--r--tutorius/linear_creator.py95
-rw-r--r--tutorius/overlayer.py503
-rw-r--r--tutorius/properties.py323
-rw-r--r--tutorius/services.py69
-rw-r--r--tutorius/testwin.py92
-rw-r--r--tutorius/textbubble.py109
-rw-r--r--tutorius/uam/Makefile.am5
-rw-r--r--tutorius/uam/__init__.py88
-rw-r--r--tutorius/uam/gobjectparser.py27
-rw-r--r--tutorius/uam/gtkparser.py44
22 files changed, 4285 insertions, 0 deletions
diff --git a/tutorius/Makefile.am b/tutorius/Makefile.am
new file mode 100644
index 0000000..072a119
--- /dev/null
+++ b/tutorius/Makefile.am
@@ -0,0 +1,19 @@
+SUBDIRS = uam addons
+
+sugardir = $(pythondir)/sugar/tutorius
+sugar_PYTHON = \
+ __init__.py \
+ core.py \
+ dialog.py \
+ actions.py \
+ gtkutils.py \
+ filters.py \
+ services.py \
+ overlayer.py \
+ editor.py \
+ constraints.py \
+ properties.py \
+ creator.py \
+ bundler.py \
+ linear_creator.py \
+ addon.py
diff --git a/tutorius/__init__.py b/tutorius/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tutorius/__init__.py
diff --git a/tutorius/actions.py b/tutorius/actions.py
new file mode 100644
index 0000000..4269cd7
--- /dev/null
+++ b/tutorius/actions.py
@@ -0,0 +1,324 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+This module defines Actions that can be done and undone on a state
+"""
+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.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"""
+ def __init__(self, widget, position, draggable=False):
+ """
+ Creates a wrapper to allow gtk widgets to be mouse dragged, if the
+ parent container supports the move() method, like a gtk.Layout.
+ @param widget the widget to enhance with drag capability
+ @param position the widget's position. Will translate the widget if needed
+ @param draggable wether to enable the drag functionality now
+ """
+ self._widget = widget
+ self._eventbox = None
+ self._drag_on = False # whether dragging is enabled
+ self._rel_pos = (0,0) # mouse pos relative to widget
+ self._handles = [] # event handlers
+ self._dragging = False # whether a drag is in progress
+ self.position = position # position of the widget
+
+ self.draggable = draggable
+
+ def _pressed_cb(self, widget, evt):
+ """Callback for start of drag event"""
+ self._eventbox.grab_add()
+ self._dragging = True
+ self._rel_pos = evt.get_coords()
+
+ def _moved_cb(self, widget, evt):
+ """Callback for mouse drag events"""
+ if not self._dragging:
+ return
+
+ # Focus on a widget before dragging another would
+ # create addititonal move event, making the widget jump unexpectedly.
+ # Solution found was to process those focus events before dragging.
+ if gtk.events_pending():
+ return
+
+ xrel, yrel = self._rel_pos
+ xparent, yparent = evt.get_coords()
+ xparent, yparent = widget.translate_coordinates(widget.parent,
+ xparent, yparent)
+ self.position = (xparent-xrel, yparent-yrel)
+ self._widget.parent.move(self._eventbox, *self.position)
+ self._widget.parent.move(self._widget, *self.position)
+ self._widget.parent.queue_draw()
+
+ def _released_cb(self, *args):
+ """Callback for end of drag (mouse release)."""
+ self._eventbox.grab_remove()
+ self._dragging = False
+
+ def _drag_end(self, *args):
+ """Callback for end of drag (stolen focus)."""
+ self._dragging = False
+
+ def set_draggable(self, value):
+ """Setter for the draggable property"""
+ if bool(value) ^ bool(self._drag_on):
+ if value:
+ self._eventbox = gtk.EventBox()
+ self._eventbox.show()
+ self._eventbox.set_visible_window(False)
+ size = self._widget.size_request()
+ self._eventbox.set_size_request(*size)
+ self._widget.parent.put(self._eventbox, *self.position)
+ self._handles.append(self._eventbox.connect(
+ "button-press-event", self._pressed_cb))
+ self._handles.append(self._eventbox.connect(
+ "button-release-event", self._released_cb))
+ self._handles.append(self._eventbox.connect(
+ "motion-notify-event", self._moved_cb))
+ self._handles.append(self._eventbox.connect(
+ "grab-broken-event", self._drag_end))
+ else:
+ while len(self._handles):
+ handle = self._handles.pop()
+ self._eventbox.disconnect(handle)
+ self._eventbox.parent.remove(self._eventbox)
+ self._eventbox.destroy()
+ self._eventbox = None
+ self._drag_on = value
+
+ def get_draggable(self):
+ """Getter for the draggable property"""
+ return self._drag_on
+
+ draggable = property(fset=set_draggable, fget=get_draggable, \
+ doc="Property to enable the draggable behaviour of the widget")
+
+ def set_widget(self, widget):
+ """Setter for the widget property"""
+ if self._dragging or self._drag_on:
+ raise Exception("Can't change widget while dragging is enabled.")
+
+ assert hasattr(widget, "parent"), "wrapped widget should have a parent"
+ parent = widget.parent
+ assert hasattr(parent, "move"), "container of widget need move method"
+ self._widget = widget
+
+ def get_widget(self):
+ """Getter for the widget property"""
+ return self._widget
+
+ widget = property(fset=set_widget, fget=get_widget)
+
+class Action(TPropContainer):
+ """Base class for Actions"""
+ def __init__(self):
+ TPropContainer.__init__(self)
+ self.position = (0,0)
+ self._drag = None
+
+ def do(self, **kwargs):
+ """
+ Perform the action
+ """
+ raise NotImplementedError("Not implemented")
+
+ def undo(self):
+ """
+ Revert anything the action has changed
+ """
+ pass #Should raise NotImplemented?
+
+ def enter_editmode(self, **kwargs):
+ """
+ Enters edit mode. The action should display itself in some way,
+ without affecting the currently running application. The default is
+ a small box with the action icon.
+ """
+ meta = addon.get_addon_meta(type(self).__name__)
+
+ actionicon = icon.Icon(icon_name=meta['icon'],
+ icon_size=gtk.ICON_SIZE_LARGE_TOOLBAR)
+ # Eventbox create a visible window for the icon, so it clips correctly
+ self.__edit_img = gtk.EventBox()
+ self.__edit_img.set_visible_window(True)
+ self.__edit_img.add(actionicon)
+
+ x, y = self.position
+
+ ObjectStore().activity._overlayer.put(self.__edit_img, x, y)
+ self.__edit_img.show_all()
+ self._drag = DragWrapper(self.__edit_img, self.position, True)
+
+ def exit_editmode(self, **kwargs):
+ x, y = self._drag.position
+ self.position = [int(x), int(y)]
+ self.__edit_img.destroy()
+
+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
new file mode 100644
index 0000000..51791d1
--- /dev/null
+++ b/tutorius/addon.py
@@ -0,0 +1,76 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Simon Poirier <simpoir@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 manages the loading and listing of tutorius addons.
+Addons are modular actions and events that are package in such a way that they
+can be autodetected and can integrate with Tutorius components (the editor)
+without any configuration or explicit dependencies (python import).
+
+An action addon is expected to have a metadata dict such as this one:
+__action__ = {
+ "name" : "HelloWorld",
+ "display_name" : "Hello World!",
+ "icon" : "hello",
+ "class" : HelloAction,
+ "mandatory_props" : ["text"],
+}
+"""
+
+import os
+import re
+import logging
+
+PREFIX = __name__+"s"
+PATH = re.sub("addon\\.py[c]$", "", __file__)+"addons"
+
+_cache = None
+
+def _reload_addons():
+ global _cache
+ _cache = {}
+ for addon in filter(lambda x: x.endswith("py"), os.listdir(PATH)):
+ mod = __import__(PREFIX+'.'+re.sub("\\.py$", "", addon), {}, {}, [""])
+ if hasattr(mod, "__action__"):
+ _cache[mod.__action__['name']] = mod.__action__
+ continue
+ if hasattr(mod, "__event__"):
+ _cache[mod.__event__['name']] = mod.__event__
+
+def create(name, *args, **kwargs):
+ global _cache
+ if not _cache:
+ _reload_addons()
+ try:
+ return _cache[name]['class'](*args, **kwargs)
+ except KeyError:
+ logging.error("Addon not found for class '%s'", name)
+ return None
+
+def list_addons():
+ global _cache
+ if not _cache:
+ _reload_addons()
+ return _cache.keys()
+
+def get_addon_meta(name):
+ global _cache
+ if not _cache:
+ _reload_addons()
+ return _cache[name]
+
+# vim:set ts=4 sts=4 sw=4 et:
diff --git a/tutorius/bundler.py b/tutorius/bundler.py
new file mode 100644
index 0000000..8e7fc3d
--- /dev/null
+++ b/tutorius/bundler.py
@@ -0,0 +1,556 @@
+# 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():
+ return os.path.join(os.getenv("HOME"),".sugar",os.getenv("SUGAR_PROFILE"),"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
new file mode 100644
index 0000000..36abdfb
--- /dev/null
+++ b/tutorius/constraints.py
@@ -0,0 +1,207 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+Constraints
+
+Defines a set of constraints with their related errors. These constraints are
+made to be used inside TutoriusProperties in order to limit the values that
+they might take. They can also be used to enforce a particular format or type
+for some properties.
+"""
+
+# For the File Constraint
+import os
+
+class Constraint():
+ """
+ Basic block for defining constraints on a TutoriusProperty. Every class
+ inheriting from Constraint will have a validate function that will be
+ executed when the property's value is to be changed.
+ """
+ def validate(self, value):
+ """
+ This function receives the value that is proposed as a new value for
+ the property. It needs to raise an Error in the case where the value
+ does not respect this constraint.
+ """
+ raise NotImplementedError("Unable to validate a base Constraint")
+
+class ValueConstraint(Constraint):
+ """
+ A value constraint contains a _limit member that can be used in a children
+ class as a basic value. See UpperLimitConstraint for an exemple.
+ """
+ def __init__(self, limit):
+ self.limit = limit
+
+class UpperLimitConstraintError(Exception):
+ pass
+
+class UpperLimitConstraint(ValueConstraint):
+ def validate(self, value):
+ """
+ Evaluates whether the given value is smaller than the limit.
+
+ @raise UpperLimitConstraintError When the value is strictly higher than
+ the limit.
+ """
+ if self.limit is not None:
+ if self.limit >= value:
+ return
+ raise UpperLimitConstraintError()
+ return
+
+class LowerLimitConstraintError(Exception):
+ pass
+
+class LowerLimitConstraint(ValueConstraint):
+ def validate(self, value):
+ """
+ If the value is lower than the limit, this function raises an error.
+
+ @raise LowerLimitConstraintError When the value is strictly smaller
+ than the limit.
+ """
+ if self.limit is not None:
+ if value >= self.limit:
+ return
+ raise LowerLimitConstraintError()
+ return
+
+class MaxSizeConstraintError(Exception):
+ pass
+
+class MaxSizeConstraint(ValueConstraint):
+ def validate(self, value):
+ """
+ Evaluate whether a given object is smaller than the given size when
+ run through len(). Great for string, lists and the like. ;)
+
+ @raise SizeConstraintError If the length of the value is strictly
+ bigger than the limit.
+ """
+ if self.limit is not None:
+ if self.limit >= len(value):
+ return
+ raise MaxSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit))
+ return
+
+class MinSizeConstraintError(Exception):
+ pass
+
+class MinSizeConstraint(ValueConstraint):
+ def validate(self, value):
+ """
+ Evaluate whether a given object is smaller than the given size when
+ run through len(). Great for string, lists and the like. ;)
+
+ @raise SizeConstraintError If the length of the value is strictly
+ bigger than the limit.
+ """
+ if self.limit is not None:
+ if self.limit <= len(value):
+ return
+ raise MinSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit))
+ return
+
+class ColorConstraintError(Exception):
+ pass
+
+class ColorArraySizeError(ColorConstraintError):
+ pass
+
+class ColorTypeError(ColorConstraintError):
+ pass
+
+class ColorValueError(ColorConstraintError):
+ pass
+
+class ColorConstraint(Constraint):
+ """
+ Validates that the value is an array of size 3 with three numbers between
+ 0 and 255 (inclusively) in it.
+
+ """
+ def validate(self, value):
+ if len(value) != 3:
+ raise ColorArraySizeError("The value is not an array of size 3")
+
+ if not (type(value[0]) == type(22) and type(value[1]) == type(22) and type(value[2]) == type(22)):
+ raise ColorTypeError("Not all the elements of the array are integers")
+
+ if value[0] > 255 or value[0] <0:
+ raise ColorValueError("Red value is not between 0 and 255")
+
+ if value[1] > 255 or value[1] <0:
+ raise ColorValueError("Green value is not between 0 and 255")
+
+ if value[2] > 255 or value[2] <0:
+ raise ColorValueError("Blue value is not between 0 and 255")
+
+ return
+
+class BooleanConstraintError(Exception):
+ pass
+
+class BooleanConstraint(Constraint):
+ """
+ Validates that the value is either True or False.
+ """
+ def validate(self, value):
+ if value == True or value == False:
+ return
+ raise BooleanConstraintError("Value is not True or False")
+
+class EnumConstraintError(Exception):
+ pass
+
+class EnumConstraint(Constraint):
+ """
+ Validates that the value is part of a set of well-defined values.
+ """
+ def __init__(self, accepted_values):
+ """
+ Creates the constraint and stores the list of accepted values.
+
+ @param correct_values A list that contains all the values that will
+ be declared as satisfying the constraint
+ """
+ self._accepted_values = accepted_values
+
+ def validate(self, value):
+ """
+ Ensures that the value that is passed is part of the list of accepted
+ values.
+ """
+ if not value in self._accepted_values:
+ raise EnumConstraintError("Value is not part of the enumeration")
+ return
+
+class FileConstraintError(Exception):
+ pass
+
+class FileConstraint(Constraint):
+ """
+ Ensures that the string given corresponds to an existing file on disk.
+ """
+ def validate(self, value):
+ # TODO : Decide on the architecture for file retrieval on disk
+ # Relative paths? From where? Support macros?
+ #
+ if not os.path.isfile(value):
+ raise FileConstraintError("Non-existing file : %s"%value)
+ return
+
diff --git a/tutorius/core.py b/tutorius/core.py
new file mode 100644
index 0000000..dd2435e
--- /dev/null
+++ b/tutorius/core.py
@@ -0,0 +1,528 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+"""
+Core
+
+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
+
+logger = logging.getLogger("tutorius")
+
+class Tutorial (object):
+ """
+ Tutorial Class, used to run through the FSM.
+ """
+
+ def __init__(self, name, fsm,filename= None):
+ """
+ Creates an unattached tutorial.
+ """
+ object.__init__(self)
+ self.name = name
+ self.activity_init_state_filename = filename
+
+ self.state_machine = fsm
+ self.state_machine.set_tutorial(self)
+
+ self.state = None
+
+ self.handlers = []
+ self.activity = None
+ #Rest of initialisation happens when attached
+
+ def attach(self, activity):
+ """
+ Attach to a running activity
+
+ @param activity the activity to attach to
+ """
+ #For now, absolutely detach if a previous one!
+ if self.activity:
+ self.detach()
+ self.activity = activity
+ ObjectStore().activity = activity
+ ObjectStore().tutorial = self
+ self._prepare_activity()
+ self.state_machine.set_state("INIT")
+
+ def detach(self):
+ """
+ Detach from the current activity
+ """
+
+ # Uninstall the whole FSM
+ self.state_machine.teardown()
+
+ #FIXME There should be some amount of resetting done here...
+ self.activity = None
+
+
+ def set_state(self, name):
+ """
+ Switch to a new state
+ """
+ logger.debug("==== NEW STATE: %s ====" % name)
+
+ 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
+ emitting gtk signals
+ """
+ #Load the saved activity if any
+ if self.activity_init_state_filename is not None:
+ #For now the file will be saved in the data folder
+ #of the activity root directory
+ filename = os.getenv("SUGAR_ACTIVITY_ROOT") + "/data/" +\
+ self.activity_init_state_filename
+ if os.path.exists(filename):
+ self.activity.read_file(filename)
+
+
+class State(object):
+ """
+ This is a step in a tutorial. The state represents a collection of actions
+ to undertake when entering the state, and a series of event filters
+ with associated actions that point to a possible next state.
+ """
+
+ def __init__(self, name="", action_list=None, event_filter_list=None, tutorial=None):
+ """
+ Initializes the content of the state, like loading the actions
+ that are required and building the correct tests.
+
+ @param action_list The list of actions to execute when entering this
+ state
+ @param event_filter_list A list of tuples of the form
+ (event_filter, next_state_name), that explains the outgoing links for
+ this state
+ @param tutorial The higher level container of the state
+ """
+ object.__init__(self)
+
+ self.name = name
+
+ self._actions = action_list or []
+
+ # Unused for now
+ #self.tests = []
+
+ self._event_filters = event_filter_list or []
+
+ self.tutorial = tutorial
+
+ def set_tutorial(self, tutorial):
+ """
+ Associates this state with a tutorial. A tutorial must be set prior
+ to executing anything in the state. The reason for this is that the
+ states need to have access to the activity (via the tutorial) in order
+ to properly register their callbacks on the activities' widgets.
+
+ @param tutorial The tutorial that this state runs under.
+ """
+ if self.tutorial == None :
+ self.tutorial = tutorial
+ else:
+ raise RuntimeWarning(\
+ "The state %s was already associated with a tutorial." % self.name)
+
+ def setup(self):
+ """
+ Install the state itself, by first registering the event filters
+ and then triggering the actions.
+ """
+ for eventfilter in self._event_filters:
+ eventfilter.install_handlers(self._event_filter_state_done_cb,
+ activity=self.tutorial.activity)
+
+ for action in self._actions:
+ action.do()
+
+ def teardown(self):
+ """
+ Uninstall all the event filters that were active in this state.
+ Also undo every action that was installed for this state. This means
+ removing dialogs that were displayed, removing highlights, etc...
+ """
+ # Remove the handlers for the all of the state's event filters
+ for event_filter in self._event_filters:
+ event_filter.remove_handlers()
+
+ # Undo all the actions related to this state
+ for action in self._actions:
+ action.undo()
+
+ def _event_filter_state_done_cb(self, event_filter):
+ """
+ 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
+ """
+ # 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())
+
+ # 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)
+
+ @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
+
+ # remove_action - We did not define names for the action, hence they're
+ # pretty hard to remove on a precise basis
+
+ def get_action_list(self):
+ """
+ @return A list of actions that the state will execute
+ """
+ return self._actions
+
+ def clear_actions(self):
+ """
+ Removes all the action associated with this state. A cleared state will
+ not do anything when entered or exited.
+ """
+ self._actions = []
+
+ def add_event_filter(self, event_filter):
+ """
+ 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
+ @return True if added, False otherwise
+ """
+ if event_filter not in self._event_filters:
+ self._event_filters.append(event_filter)
+ return True
+ return False
+
+ def get_event_filter_list(self):
+ """
+ @return The list of event filters associated with this state.
+ """
+ return self._event_filters
+
+ def clear_event_filters(self):
+ """
+ Removes all the event filters associated with this state. A state that
+ was just cleared will become a sink and will be the end of the
+ tutorial.
+ """
+ self._event_filters = []
+
+class FiniteStateMachine(State):
+ """
+ This is a collection of states, with a start state and an end callback.
+ It is used to simplify the development of the various tutorials by
+ encapsulating a collection of states that represent a given learning
+ process.
+
+ For now, we will consider that there can only be states
+ inserted in the FSM, and that there are no nested FSM inside.
+ """
+
+ def __init__(self, name, tutorial=None, state_dict=None, start_state_name="INIT", action_list=None):
+ """
+ The constructor for a FSM. Pass in the start state and the setup
+ actions that need to be taken when the FSM itself start (which may be
+ different from what is done in the first state of the machine).
+
+ @param name A short descriptive name for this FSM
+ @param tutorial The tutorial that will execute this FSM. If None is
+ attached on creation, then one must absolutely be attached before
+ executing the FSM with set_tutorial().
+ @param state_dict A dictionary containing the state names as keys and
+ the state themselves as entries.
+ @param start_state_name The name of the starting state, if different
+ from "INIT"
+ @param action_list The actions to undertake when initializing the FSM
+ """
+ State.__init__(self, name)
+
+ self.name = name
+ self.tutorial = tutorial
+
+ # Dictionnary of states contained in the FSM
+ self._states = state_dict or {}
+
+ self.start_state_name = start_state_name
+ # Set the current state to None - we are not executing anything yet
+ self.current_state = None
+
+ # Register the actions for the FSM - They will be processed at the
+ # FSM level, meaning that when the FSM will start, it will first
+ # execute those actions. When the FSM closes, it will tear down the
+ # inner actions of the state, then close its own actions
+ self.actions = action_list or []
+
+ # Flag to mention that the FSM was initialized
+ self._fsm_setup_done = False
+ # Flag that must be raised when the FSM is to be teared down
+ self._fsm_teardown_done = False
+ # Flag used to declare that the FSM has reached an end state
+ self._fsm_has_finished = False
+
+ def set_tutorial(self, tutorial):
+ """
+ This associates the FSM to the given tutorial. It MUST be associated
+ either in the constructor or with this function prior to executing the
+ FSM.
+
+ @param tutorial The tutorial that will execute this FSM.
+ """
+ # If there was no tutorial associated
+ if self.tutorial == None:
+ # Associate it with this FSM and all the underlying states
+ self.tutorial = tutorial
+ for state in self._states.itervalues():
+ state.set_tutorial(tutorial)
+ else:
+ raise RuntimeWarning(\
+ "The FSM %s is already associated with a tutorial."%self.name)
+
+ def setup(self):
+ """
+ This function initializes the FSM the first time it is called.
+ Then, every time it is called, it initializes the current state.
+ """
+ # Are we associated with a tutorial?
+ if self.tutorial == None:
+ raise UnboundLocalError("No tutorial was associated with FSM %s" % self.name)
+
+ # If we never initialized the FSM itself, then we need to run all the
+ # actions associated with the FSM.
+ if self._fsm_setup_done == False:
+ # Remember the initial state - we might want to reset
+ # or rewind the FSM at a later moment
+ self.start_state = self._states[self.start_state_name]
+ self.current_state = self.start_state
+ # Flag the FSM level setup as done
+ self._fsm_setup_done = True
+ # Execute all the FSM level actions
+ for action in self.actions:
+ action.do()
+
+ # Then, we need to run the setup of the current state
+ self.current_state.setup()
+
+ def set_state(self, new_state_name):
+ """
+ This functions changes the current state of the finite state machine.
+
+ @param new_state The identifier of the state we need to go to
+ """
+ # TODO : Since we assume no nested FSMs, we don't set state on the
+ # inner States / FSMs
+## # Pass in the name to the internal state - it might be a FSM and
+## # this name will apply to it
+## self.current_state.set_state(new_state_name)
+
+ # Make sure the given state is owned by the FSM
+ if not self._states.has_key(new_state_name):
+ # If we did not recognize the name, then we do not possess any
+ # state by that name - we must ignore this state change request as
+ # it will be done elsewhere in the hierarchy (or it's just bogus).
+ return
+
+ if self.current_state != None:
+ if new_state_name == self.current_state.name:
+ # If we already are in this state, we do not need to change
+ # anything in the current state - By design, a state may not point
+ # to itself
+ return
+
+ new_state = self._states[new_state_name]
+
+ # Undo the actions of the old state
+ self.teardown()
+
+ # Insert the new state
+ self.current_state = new_state
+
+ # Call the initial actions in the new state
+ self.setup()
+
+ def get_current_state_name(self):
+ """
+ Returns the name of the current state.
+
+ @return A string representing the name of the current state
+ """
+ return self.current_state.name
+
+ def teardown(self):
+ """
+ Revert any changes done by setup()
+ """
+ # Teardown the current state
+ if self.current_state is not None:
+ self.current_state.teardown()
+
+ # If we just finished the whole FSM, we need to also call the teardown
+ # on the FSM level actions
+ if self._fsm_has_finished == True:
+ # Flag the FSM teardown as not needed anymore
+ self._fsm_teardown_done = True
+ # Undo all the FSM level actions here
+ for action in self.actions:
+ action.undo()
+
+ # TODO : It might be nice to have a start() and stop() method for the
+ # FSM.
+
+ # Data manipulation section
+ # These functions are dedicated to the building and editing of a graph.
+ def add_state(self, new_state):
+ """
+ Inserts a new state in the FSM.
+
+ @param new_state The State object that will now be part of the FSM
+ @raise KeyError In the case where a state with this name already exists
+ """
+ if self._states.has_key(new_state.name):
+ raise KeyError("There is already a state by this name in the FSM")
+
+ self._states[new_state.name] = new_state
+
+ # Not such a great name for the state accessor... We already have a
+ # set_state name, so get_state would conflict with the notion of current
+ # state - I would recommend having a set_current_state instead.
+ def get_state_by_name(self, state_name):
+ """
+ Fetches a state from the FSM, based on its name. If there is no
+ such state, the method will throw a KeyError.
+
+ @param state_name The name of the desired state
+ @return The State object having the given name
+ """
+ return self._states[state_name]
+
+ def remove_state(self, state_name):
+ """
+ Removes a state from the FSM. Raises a KeyError when the state is
+ not existent.
+
+ Warning : removing a state will also remove all the event filters that
+ point to this given name, to preserve the FSM's integrity. If you only
+ want to edit a state, you would be better off fetching this state with
+ get_state_by_name().
+
+ @param state_name A string being the name of the state to remove
+ @raise KeyError When the state_name does not a represent a real state
+ stored in the dictionary
+ """
+
+ state_to_remove = self._states[state_name]
+
+ # Remove the state from the states' dictionnary
+ for st in self._states.itervalues():
+ # Iterate through the list of event filters and remove those
+ # that point to the state that will be removed
+
+ #TODO : Move this code inside the State itself - we're breaking
+ # encap :P
+ for event_filter in st._event_filters:
+ if event_filter.get_next_state() == state_name:
+ st._event_filters.remove(event_filter)
+
+ # Remove the state from the dictionary
+ del self._states[state_name]
+
+ # Exploration methods - used to know more about a given state
+ def get_following_states(self, state_name):
+ """
+ Returns a tuple of the names of the states that point to the given
+ state. If there is no such state, the function raises a KeyError.
+
+ @param state_name The name of the state to analyse
+ @raise KeyError When there is no state by this name in the FSM
+ """
+ state = self._states[state_name]
+
+ next_states = set()
+
+ for event_filter in state._event_filters:
+ next_states.add(event_filter.get_next_state())
+
+ return tuple(next_states)
+
+ def get_previous_states(self, state_name):
+ """
+ Returns a tuple of the names of the state that can transition to
+ the given state. If there is no such state, the function raises a
+ KeyError.
+
+ @param state_name The name of the state that the returned states might
+ transition to.
+ """
+ # This might seem a bit funny, but we don't verify if the given
+ # state is present or not in the dictionary.
+ # This is due to the fact that when building a graph, we might have a
+ # prototypal state that has not been inserted yet. We could not know
+ # which states are pointing to it until we insert it in the graph.
+
+ states = []
+ # Walk through the list of states
+ for st in self._states.itervalues():
+ for event_filter in st._event_filters:
+ if event_filter.get_next_state() == state_name:
+ states.append(event_filter.get_next_state())
+ continue
+
+ return tuple(states)
+
+ # Convenience methods to see the content of a FSM
+ def __str__(self):
+ out_string = ""
+ for st in self._states.itervalues():
+ out_string += st.name + ", "
+ return out_string
diff --git a/tutorius/creator.py b/tutorius/creator.py
new file mode 100644
index 0000000..7455ecb
--- /dev/null
+++ b/tutorius/creator.py
@@ -0,0 +1,436 @@
+"""
+This package contains UI classes related to tutorial authoring.
+This includes visual display of tools to edit and create tutorials from within
+the activity itself.
+"""
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Simon Poirier <simpoir@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 1 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import gtk.gdk
+import gobject
+from gettext import gettext as T
+
+from sugar.graphics.toolbutton import ToolButton
+
+from sugar.tutorius import overlayer, gtkutils, actions, bundler, properties, addon
+from sugar.tutorius import filters
+from sugar.tutorius.services import ObjectStore
+from sugar.tutorius.linear_creator import LinearCreator
+from sugar.tutorius.core import Tutorial
+
+class Creator(object):
+ """
+ Class acting as a bridge between the creator, serialization and core
+ classes. This contains most of the UI part of the editor.
+ """
+ def __init__(self, activity, tutorial=None):
+ """
+ Instanciate a tutorial creator for the activity.
+
+ @param activity to bind the creator to
+ @param tutorial an existing tutorial to edit, or None to create one
+ """
+ self._activity = activity
+ if not tutorial:
+ self._tutorial = LinearCreator()
+ else:
+ self._tutorial = tutorial
+
+ 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._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):
+ """
+ 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:
+ 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()
+
+ def _intro_cb(self, widget, evt):
+ """
+ Callback for capture of widget events, when in introspect mode.
+ """
+ if evt.type == gtk.gdk.BUTTON_PRESS:
+ # widget has focus, let's hilight it
+ win = gtk.gdk.display_get_default().get_window_at_pointer()
+ click_wdg = win[0].get_user_data()
+ if not click_wdg.is_ancestor(self._activity._overlayer):
+ # as popups are not (yet) supported, it would break
+ # badly if we were to play with a widget not in the
+ # hierarchy.
+ return
+ for hole in self._intro_mask.pass_thru:
+ self._intro_mask.mask(hole)
+ self._intro_mask.unmask(click_wdg)
+ self._selected_widget = gtkutils.raddr_lookup(click_wdg)
+
+ if self._eventmenu:
+ self._eventmenu.destroy()
+ self._eventmenu = gtk.Menu()
+ menuitem = gtk.MenuItem(label=type(click_wdg).__name__)
+ menuitem.set_sensitive(False)
+ self._eventmenu.append(menuitem)
+ self._eventmenu.append(gtk.MenuItem())
+
+ for item in gobject.signal_list_names(click_wdg):
+ menuitem = gtk.MenuItem(label=item)
+ menuitem.connect("activate", self._evfilt_cb, item)
+ self._eventmenu.append(menuitem)
+ self._eventmenu.show_all()
+ self._eventmenu.popup(None, None, None, evt.button, evt.time)
+ self._activity.queue_draw()
+
+ def 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):
+ """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)
+ 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)
+
+
+ def _action_refresh_cb(self, widget, evt, action):
+ """
+ Callback for refreshing properties values and notifying the
+ property dialog of the new values.
+ """
+ action.exit_editmode()
+ action.enter_editmode()
+ self._activity.queue_draw()
+ # TODO: replace following with event catching
+ action._drag._eventbox.connect_after(
+ "button-release-event", self._action_refresh_cb, action)
+ self._propedit.action = action
+
+ def _add_step_cb(self, widget):
+ """Callback for the "add step" tool"""
+ self.introspecting = True
+
+ 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:
+ 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)
+
+ # 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._activity.queue_draw()
+ del self._activity._creator
+
+ def launch(*args, **kwargs):
+ """
+ Launch and attach a creator to the currently running activity.
+ """
+ activity = ObjectStore().activity
+ if not hasattr(activity, "_creator"):
+ activity._creator = Creator(activity)
+ launch = staticmethod(launch)
+
+class EditToolBox(gtk.Window):
+ """Helper toolbox class for managing action properties"""
+ def __init__(self, parent, action=None):
+ """
+ Create the property edition toolbox and display it.
+
+ @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.show_all()
+ self.move(sw-10-200, (sh-400)/2)
+
+ def refresh(self):
+ """Refresh property values from the selected action."""
+ if self._action is None:
+ return
+ props = self._action._props.keys()
+ for propnum in xrange(len(props)):
+ row = self._propbox.get_children()[propnum]
+ propname = props[propnum]
+ prop = getattr(type(self._action), propname)
+ propval = getattr(self._action, propname)
+ if isinstance(prop, properties.TStringProperty):
+ propwdg = row.get_children()[1]
+ propwdg.get_buffer().set_text(propval)
+ elif isinstance(prop, properties.TIntProperty):
+ propwdg = row.get_children()[1]
+ propwdg.set_value(propval)
+ elif isinstance(prop, properties.TArrayProperty):
+ propwdg = row.get_children()[1]
+ for i in xrange(len(propval)):
+ entry = propwdg.get_children()[i]
+ entry.set_text(str(propval[i]))
+ else:
+ propwdg = row.get_children()[1]
+ propwdg.set_text(str(propval))
+
+ def set_action(self, action):
+ """Setter for the action property."""
+ if self._action is action:
+ self.refresh()
+ return
+ parent = self._propbox.get_parent()
+ parent.remove(self._propbox)
+ self._propbox = gtk.VBox(spacing=10)
+ parent.add(self._propbox)
+
+ self._action = action
+ if action is None:
+ return
+ for propname in action._props.keys():
+ row = gtk.HBox()
+ row.pack_start(gtk.Label(T(propname)), False, False, 10)
+ prop = getattr(type(action), propname)
+ propval = getattr(action, propname)
+ if isinstance(prop, properties.TStringProperty):
+ propwdg = gtk.TextView()
+ propwdg.get_buffer().set_text(propval)
+ propwdg.connect_after("focus-out-event", \
+ self._str_prop_changed, action, propname)
+ elif isinstance(prop, properties.TIntProperty):
+ adjustment = gtk.Adjustment(value=propval,
+ lower=prop.lower_limit.limit,
+ upper=prop.upper_limit.limit,
+ step_incr=1)
+ propwdg = gtk.SpinButton(adjustment=adjustment)
+ propwdg.connect_after("focus-out-event", \
+ self._int_prop_changed, action, prop)
+ elif isinstance(prop, properties.TArrayProperty):
+ propwdg = gtk.HBox()
+ for i in xrange(len(propval)):
+ entry = gtk.Entry()
+ propwdg.pack_start(entry)
+ entry.connect_after("focus-out-event", \
+ self._list_prop_changed, action, propname, i)
+ else:
+ propwdg = gtk.Entry()
+ propwdg.set_text(str(propval))
+ row.pack_end(propwdg)
+ self._propbox.pack_start(row, expand=False)
+ self._vbox.show_all()
+ self.refresh()
+
+ def get_action(self):
+ """Getter for the action property"""
+ return self._action
+ action = property(fset=set_action, fget=get_action, doc=\
+ "Action to be edited through introspection.")
+
+ def _list_prop_changed(self, widget, evt, action, propname, idx):
+ try:
+ getattr(action, propname)[idx] = int(widget.get_text())
+ except ValueError:
+ widget.set_text(str(getattr(action, propname)[idx]))
+ self.__parent._creator._action_refresh_cb(None, None, action)
+ def _str_prop_changed(self, widget, evt, action, propname):
+ buf = widget.get_buffer()
+ setattr(action, propname, buf.get_text(buf.get_start_iter(), buf.get_end_iter()))
+ self.__parent._creator._action_refresh_cb(None, None, action)
+ def _int_prop_changed(self, widget, evt, action, prop):
+ setattr(action, propname, widget.get_value_as_int())
+ self.__parent._creator._action_refresh_cb(None, None, action)
+
+class TextInputDialog(gtk.MessageDialog):
+ def __init__(self, text, field):
+ gtk.MessageDialog.__init__(self, None,
+ gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
+ gtk.MESSAGE_QUESTION,
+ gtk.BUTTONS_OK,
+ None)
+ self.set_markup(text)
+ self.entry = gtk.Entry()
+ self.entry.connect("activate", self._dialog_done_cb, gtk.RESPONSE_OK)
+ hbox = gtk.HBox()
+ lbl = gtk.Label(field)
+ hbox.pack_start(lbl, False)
+ hbox.pack_end(self.entry)
+ self.vbox.pack_end(hbox, True, True)
+ self.show_all()
+
+ def pop(self):
+ self.run()
+ self.hide()
+ text = self.entry.get_text()
+ return text
+
+ def _dialog_done_cb(self, entry, response):
+ self.response(response)
+
+# vim:set ts=4 sts=4 sw=4 et:
diff --git a/tutorius/dialog.py b/tutorius/dialog.py
new file mode 100644
index 0000000..be51a0e
--- /dev/null
+++ b/tutorius/dialog.py
@@ -0,0 +1,59 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+The Dialog module provides means of interacting with the user
+through the use of Dialogs.
+"""
+import gtk
+
+class TutoriusDialog(gtk.Dialog):
+ """
+ TutoriusDialog is a simple wrapper around gtk.Dialog.
+
+ It allows creating and showing a dialog and connecting the response and
+ button click events to callbacks.
+ """
+ def __init__(self, label="Hint", button_clicked_cb=None, response_cb=None):
+ """
+ Constructor.
+
+ @param label text to be shown on the dialog
+ @param button_clicked_cb callback for the button click
+ @param response_cb callback for the dialog response
+ """
+ gtk.Dialog.__init__(self)
+
+ self._button = gtk.Button(label)
+
+ self.add_action_widget(self._button, 1)
+
+ if not button_clicked_cb == None:
+ self._button.connect("clicked", button_clicked_cb)
+
+ self._button.show()
+
+ if not response_cb == None:
+ self.connect("response", response_cb)
+
+ self.set_decorated(False)
+
+ def set_button_clicked_cb(self, funct):
+ """Setter for the button_clicked callback"""
+ self._button.connect("clicked", funct)
+
+ def close_self(self, arg=None):
+ """Close the dialog"""
+ self.destroy()
diff --git a/tutorius/editor.py b/tutorius/editor.py
new file mode 100644
index 0000000..42cc718
--- /dev/null
+++ b/tutorius/editor.py
@@ -0,0 +1,318 @@
+# Copyright (C) 2009, Tutorius.org
+# Greatly influenced by sugar/activity/namingalert.py
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+""" Tutorial Editor Module
+"""
+
+import gtk
+import gobject
+#import hippo
+#import gconf
+
+from gettext import gettext as _
+
+from sugar.tutorius.gtkutils import register_signals_numbered, get_children
+
+class WidgetIdentifier(gtk.Window):
+ """
+ Tool that allows identifying widgets.
+
+ """
+ __gtype_name__ = 'TutoriusWidgetIdentifier'
+
+ def __init__(self, activity):
+ gtk.Window.__init__(self)
+
+ self._activity = activity
+ self._handlers = {}
+ # dict of signals to register on the widgets.
+ # key : signal name
+ # value : initial checkbox status
+ signals = {
+ "focus":True,
+ "button-press-event":True,
+ "enter-notify-event":False,
+ "leave-notify-event":False,
+ "key-press-event":True,
+ "text-selected":True,
+ "clicked":True,
+ }
+
+ self.set_decorated(False)
+ self.set_resizable(False)
+ self.set_modal(False)
+
+ self.connect('realize', self.__realize_cb)
+
+ self._expander = gtk.Expander(_("Widget Identifier"))
+ self._expander.set_expanded(True)
+ self.add(self._expander)
+ self._expander.connect("notify::expanded", self.__expander_cb)
+
+ self._expander.show()
+
+ nbk = gtk.Notebook()
+ self._expander.add(nbk)
+ nbk.show()
+
+ ###############################
+ # Event log viewer page
+ ###############################
+ self.logview = gtk.TextView()
+ self.logview.set_editable(False)
+ self.logview.set_cursor_visible(False)
+ self.logview.set_wrap_mode(gtk.WRAP_NONE)
+ self._textbuffer = self.logview.get_buffer()
+
+ swd = gtk.ScrolledWindow()
+ swd.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+ swd.add(self.logview)
+ self.logview.show()
+
+ nbk.append_page(swd, gtk.Label(_("Log")))
+ swd.show()
+
+ ###############################
+ # Filters page
+ ###############################
+ filters = gtk.Table( (len(signals)+1)/2, 2)
+
+ xpos, ypos = 0, 0
+ for key, active in signals.items():
+ cbtn = gtk.CheckButton(label=key)
+ filters.attach(cbtn, xpos, xpos+1, ypos, ypos+1)
+ cbtn.show()
+ cbtn.set_active(active)
+ if active:
+ self._handlers[key] = register_signals_numbered( \
+ self._activity, self._handle_events, events=(key,))
+ else:
+ self._handlers[key] = []
+
+ cbtn.connect("toggled", self.__filter_toggle_cb, key)
+
+ #Follow lines then columns
+ xpos, ypos = (xpos+1)%2, ypos+(xpos%2)
+
+ nbk.append_page(filters, gtk.Label(_("Events")))
+ filters.show()
+
+ ###############################
+ # Explorer Page
+ ###############################
+ tree = gtk.TreeStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
+ explorer = gtk.TreeView(tree)
+
+ pathrendr = gtk.CellRendererText()
+ pathrendr.set_properties(background="#ffffff", foreground="#000000")
+ pathcol = gtk.TreeViewColumn(_("Path"), pathrendr, text=0, background=0, foreground=0)
+ explorer.append_column(pathcol)
+
+ typerendr = gtk.CellRendererText()
+ typerendr.set_properties(background="#ffffff", foreground="#000000")
+ typecol = gtk.TreeViewColumn(_("Widget"), typerendr, text=1, background=1, foreground=1)
+ explorer.append_column(typecol)
+
+ self.__populate_treestore(
+ tree, #tree
+ tree.append(None, ["0",self._activity.get_name()]), #parent
+ self._activity, #widget
+ "0" #path
+ )
+
+ explorer.set_expander_column(typecol)
+
+ swd2 = gtk.ScrolledWindow()
+ swd2.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+ swd2.add(explorer)
+ explorer.show()
+ nbk.append_page(swd2, gtk.Label(_("Explorer")))
+ swd2.show()
+
+ ###############################
+ # GObject Explorer Page
+ ###############################
+ tree2 = gtk.TreeStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
+ explorer2 = gtk.TreeView(tree2)
+
+ pathrendr2 = gtk.CellRendererText()
+ pathrendr2.set_properties(background="#ffffff", foreground="#000000")
+ pathcol2 = gtk.TreeViewColumn(_("Path"), pathrendr2, text=0, background=0, foreground=0)
+ explorer2.append_column(pathcol2)
+
+ typerendr2 = gtk.CellRendererText()
+ typerendr2.set_properties(background="#ffffff", foreground="#000000")
+ typecol2 = gtk.TreeViewColumn(_("Widget"), typerendr2, text=1, background=1, foreground=1)
+ explorer2.append_column(typecol2)
+
+ self.__populate_gobject_treestore(
+ tree2, #tree
+ tree2.append(None, ["activity",self._activity.get_name()]), #parent
+ self._activity, #widget
+ "activity" #path
+ )
+
+ explorer2.set_expander_column(typecol2)
+
+ swd3 = gtk.ScrolledWindow()
+ swd3.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+ swd3.add(explorer2)
+ explorer2.show()
+ nbk.append_page(swd3, gtk.Label(_("GObject Explorer")))
+ swd3.show()
+
+ def __populate_treestore(self, tree, parent, widget, path):
+ """Populates the treestore with the widget's children recursively
+ @param tree gtk.TreeStore to populate
+ @param parent gtk.TreeIter to append to
+ @param widget gtk.Widget to check for children
+ @param path treeish of the widget
+ """
+ #DEBUG: show parameters in log window gehehe
+ #self._handle_events((path,str(type(widget))))
+ children = get_children(widget)
+ for i in xrange(len(children)):
+ childpath = ".".join([path, str(i)])
+ child = children[i]
+ self.__populate_treestore(
+ tree, #tree
+ tree.append(parent, [childpath, child.get_name()]), #parent
+ child, #widget
+ childpath #path
+ )
+
+
+ def __populate_gobject_treestore(self, tree, parent, widget, path, listed=None):
+ """Populates the treestore with the widget's children recursively
+ @param tree gtk.TreeStore to populate
+ @param parent gtk.TreeIter to append to
+ @param widget gtk.Widget to check for children
+ @param path treeish of the widget
+ """
+ listed = listed or []
+ if widget in listed:
+ return
+ listed.append(widget)
+ #DEBUG: show parameters in log window gehehe
+ #self._handle_events((path,str(type(widget))))
+ #Add a child node
+ children = tree.append(parent, ["","children"])
+ for i in dir(widget):
+ #Add if a gobject
+ try:
+ child = getattr(widget, i)
+ except:
+ continue
+ if isinstance(child,gobject.GObject):
+ childpath = ".".join([path, i])
+ child = getattr(widget, i)
+ self.__populate_gobject_treestore(
+ tree, #tree
+ tree.append(children, [childpath, i]), #parent
+ child, #widget
+ path + "." + i, #path,
+ listed
+ )
+ widgets = tree.append(parent, ["","widgets"])
+ wchildren = get_children(widget)
+ for i in xrange(len(wchildren)):
+ childpath = ".".join([path, str(i)])
+ child = wchildren[i]
+ self.__populate_gobject_treestore(
+ tree, #tree
+ tree.append(widgets, [childpath, (hasattr(child,"get_name") and child.get_name()) or i]), #parent
+ child, #widget
+ childpath, #path,
+ listed
+ )
+
+ #Add signals and attributes nodes
+ signals = tree.append(parent, ["","signals"])
+ for signame in gobject.signal_list_names(widget):
+ tree.append(signals, ["",signame])
+
+ attributes = tree.append(parent, ["","properties"])
+ for prop in gobject.list_properties(widget):
+ tree.append(attributes, ["",prop])
+
+ def __filter_toggle_cb(self, btn, eventname):
+ """Callback for signal name checkbuttons' toggling"""
+ #Disconnect existing handlers on key
+ self.__disconnect_handlers(eventname)
+ if btn.get_active():
+ #if checked, reconnect
+ self._handlers[eventname] = register_signals_numbered( \
+ self._activity, self._handle_events, events=(eventname,))
+
+
+ def __expander_cb(self, *args):
+ """Callback for the window expander toggling"""
+ if self._expander.get_expanded():
+ self.__move_expanded()
+ else:
+ self.__move_collapsed()
+
+ def __move_expanded(self):
+ """Move the window to it's expanded position"""
+ width = 500
+ height = 300
+ swidth = gtk.gdk.screen_width()
+ sheight = gtk.gdk.screen_height()
+
+ self.set_size_request(width, height)
+ self.move((swidth-width)/2, sheight-height)
+
+ def __move_collapsed(self):
+ """Move the window to it's collapsed position"""
+ width = 150
+ height = 40
+ swidth = gtk.gdk.screen_width()
+ sheight = gtk.gdk.screen_height()
+
+ self.set_size_request(width, height)
+ self.move((swidth-width)/2, sheight-height)
+
+ def __realize_cb(self, widget):
+ """Callback for realize"""
+ self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
+ self.window.set_accept_focus(True)
+ self.__move_expanded()
+
+ def _disconnect_handlers(self):
+ """ Disconnect all event handlers """
+ for key in self._handlers:
+ self.__disconnect_handlers(key)
+
+ def __disconnect_handlers(self, key):
+ """ Disconnect event handlers associated to signal name "key" """
+ if self._handlers.has_key(key):
+ for widget, handlerid in self._handlers[key]:
+ widget.handler_disconnect(handlerid)
+ del self._handlers[key]
+
+ def _handle_events(self, *args):
+ """ Event handler for subscribed widget events.
+ Accepts variable length argument list. Last must be
+ a two-tuple containing (event name, widget name) """
+ sig, name = args[-1]
+ text = "\r\n".join(
+ (["%s event received from %s" % (sig, name)] +
+ self._textbuffer.get_text(*(self._textbuffer.get_bounds())
+ ).split("\r\n"))[:80]
+ )
+ self._textbuffer.set_text(text)
+
+
diff --git a/tutorius/filters.py b/tutorius/filters.py
new file mode 100644
index 0000000..aa8c997
--- /dev/null
+++ b/tutorius/filters.py
@@ -0,0 +1,204 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+
+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
+
+
+class EventFilter(properties.TPropContainer):
+ """
+ Base class for an event filter
+ """
+
+ next_state = properties.TStringProperty("None")
+
+ def __init__(self, next_state=None):
+ """
+ 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
+ necessary event handlers to be able to catch the desired
+ event.
+
+ @param callback the callback function that will be called
+ with the event filter as an argument when the event is catched
+ and validated.
+ @param **kwargs unused by this handler for now, allows subclasses
+ to receive information about the context when installing
+
+ Subclasses must call this super method to setup the callback if they
+ feel like cooperating
+ """
+ self._callback = callback
+
+ def remove_handlers(self):
+ """
+ remove_handlers is called when a state is done so that all
+ event filters can cleanup any handlers they have installed
+
+ This function will also clear the callback function so that any
+ leftover handler that is triggered will not be able to change the
+ application state.
+
+ subclasses must call this super method to cleanup the callback if they
+ collaborate and use this classe's do_callback()
+ """
+ self._callback = None
+
+ def do_callback(self, *args, **kwargs):
+ """
+ Default callback function that calls the event filter callback
+ with the event filter as only argument.
+ """
+ if self._callback:
+ self._callback(self)
+
+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/gtkutils.py b/tutorius/gtkutils.py
new file mode 100644
index 0000000..1a9cb0f
--- /dev/null
+++ b/tutorius/gtkutils.py
@@ -0,0 +1,203 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+"""
+Utility classes and functions that are gtk related
+"""
+import gtk
+
+def raddr_lookup(widget):
+ name = []
+ child = widget
+ parent = widget.parent
+ while parent:
+ name.append(str(parent.get_children().index(child)))
+ child = parent
+ parent = child.parent
+
+ name.append("0") # root object itself
+ name.reverse()
+ return ".".join(name)
+
+
+def find_widget(base, target_fqdn):
+ """Find a widget by digging into a parent widget's children tree
+ @param base the parent widget
+ @param target_fqdn fqdn-style target object name
+
+ @return widget found
+
+ The object should normally be the activity widget, as it is the root
+ widget for activities. The target_fqdn is a dot separated list of
+ indexes used in widget.get_children and should start with a 0 which is
+ the base widget itself,
+
+ Example Usage:
+ find_widget(activity,"0.0.0.1.0.0.2")
+ """
+ path = target_fqdn.split(".")
+ #We select the first object and pop the first zero
+ obj = base
+ path.pop(0)
+
+ while len(path) > 0:
+ try:
+ obj = get_children(obj)[int(path.pop(0))]
+ except:
+ break
+
+ return obj
+
+EVENTS = (
+ "focus",
+ "button-press-event",
+ "enter-notify-event",
+ "leave-notify-event",
+ "key-press-event",
+ "text-selected",
+ "clicked",
+)
+
+IGNORED_WIDGETS = (
+ "GtkVBox",
+ "GtkHBox",
+ "GtkAlignment",
+ "GtkNotebook",
+ "GtkButton",
+ "GtkToolItem",
+ "GtkToolbar",
+)
+
+def register_signals_numbered(target, handler, prefix="0", max_depth=None, events=None):
+ """
+ Recursive function to register event handlers on an target
+ and it's children. The event handler is called with an extra
+ argument which is a two-tuple containing the signal name and
+ the FQDN-style name of the target that triggered the event.
+
+ This function registers all of the events listed in
+ EVENTS
+
+ Example arg tuple added:
+ ("focus", "1.1.2")
+ Side effects:
+ -Handlers connected on the various targets
+
+ @param target the target to recurse on
+ @param handler the handler function to connect
+ @param prefix name prepended to the target name to form a chain
+ @param max_depth maximum recursion depth, None for infinity
+
+ @returns list of (object, handler_id)
+ """
+ ret = []
+ evts = events or EVENTS
+ #Gtk Containers have a get_children() function
+ children = get_children(target)
+ for i in range(len(children)):
+ child = children[i]
+ if max_depth is None or max_depth > 0:
+ #Recurse with a prefix on all children
+ pre = ".".join( \
+ [p for p in (prefix, str(i)) if not p is None]
+ )
+ if max_depth is None:
+ dep = None
+ else:
+ dep = max_depth - 1
+ ret+=register_signals_numbered(child, handler, pre, dep, evts)
+ #register events on the target if a widget XXX necessary to check this?
+ if isinstance(target, gtk.Widget):
+ for sig in evts:
+ try:
+ ret.append( \
+ (target, target.connect(sig, handler, (sig, prefix) ))\
+ )
+ except TypeError:
+ pass
+
+ return ret
+
+def register_signals(target, handler, prefix=None, max_depth=None, events=None):
+ """
+ Recursive function to register event handlers on an target
+ and it's children. The event handler is called with an extra
+ argument which is a two-tuple containing the signal name and
+ the FQDN-style name of the target that triggered the event.
+
+ This function registers all of the events listed in
+ EVENTS and omits widgets with a name matching
+ IGNORED_WIDGETS from the name hierarchy.
+
+ Example arg tuple added:
+ ("focus", "Activity.Toolbox.Bold")
+ Side effects:
+ -Handlers connected on the various targets
+
+ @param target the target to recurse on
+ @param handler the handler function to connect
+ @param prefix name prepended to the target name to form a chain
+ @param max_depth maximum recursion depth, None for infinity
+
+ @returns list of (object, handler_id)
+ """
+ ret = []
+ evts = events or EVENTS
+ #Gtk Containers have a get_children() function
+ for child in get_children(target):
+ if max_depth is None or max_depth > 0:
+ #Recurse with a prefix on all children
+ pre = ".".join( \
+ [p for p in (prefix, target.get_name()) \
+ if not (p is None or p in IGNORED_WIDGETS)] \
+ )
+ if max_depth is None:
+ dep = None
+ else:
+ dep = max_depth - 1
+ ret += register_signals(child, handler, pre, dep, evts)
+ name = ".".join( \
+ [p for p in (prefix, target.get_name()) \
+ if not (p is None or p in IGNORED_WIDGETS)] \
+ )
+ #register events on the target if a widget XXX necessary to check this?
+ if isinstance(target, gtk.Widget):
+ for sig in evts:
+ try:
+ ret.append( \
+ (target, target.connect(sig, handler, (sig, name) )) \
+ )
+ except TypeError:
+ pass
+
+ return ret
+
+def get_children(widget):
+ """Lists widget's children"""
+ #widgets with multiple children
+ try:
+ return widget.get_children()
+ except (AttributeError,TypeError):
+ pass
+
+ #widgets with a single child
+ try:
+ return [widget.get_child(),]
+ except (AttributeError,TypeError):
+ pass
+
+ #otherwise return empty list
+ return []
diff --git a/tutorius/linear_creator.py b/tutorius/linear_creator.py
new file mode 100644
index 0000000..91b11f4
--- /dev/null
+++ b/tutorius/linear_creator.py
@@ -0,0 +1,95 @@
+# Copyright (C) 2009, Tutorius.org
+# Greatly influenced by sugar/activity/namingalert.py
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from sugar.tutorius.core import *
+from sugar.tutorius.actions import *
+from sugar.tutorius.filters import *
+
+from copy import deepcopy
+
+class LinearCreator(object):
+ """
+ This class is used to create a FSM from a linear sequence of orders. The
+ orders themselves are meant to be either an action or a transition.
+ """
+
+ def __init__(self):
+ self.fsm = FiniteStateMachine("Sample Tutorial")
+ self.current_actions = []
+ self.nb_state = 0
+ self.state_name = "INIT"
+
+ def set_name(self, name):
+ """
+ Sets the name of the generated FSM.
+ """
+ self.fsm.name = name
+
+ def action(self, action):
+ """
+ Adds an action to execute in the current state.
+ """
+ self.current_actions.append(action)
+
+ def event(self, event_filter):
+ """
+ Adds a transition to another state. When executing this, all the actions
+ previously called will be bundled in a single state, with the exit
+ condition of this state being the transition just added.
+
+ Whatever the name of the next state you inserted in the event, it will
+ be replaced to point to the next event in the line.
+ """
+ if len(self.current_actions) != 0:
+ # Set the next state name - there is no way the caller should have
+ # to deal with that.
+ next_state_name = "State %d" % (self.nb_state+1)
+ event_filter.set_next_state(next_state_name)
+ state = State(self.state_name, action_list=self.current_actions,
+ event_filter_list=[event_filter])
+ self.state_name = next_state_name
+
+ self.nb_state += 1
+ self.fsm.add_state(state)
+
+ # Clear the actions from the list
+ self.current_actions = []
+
+ def generate_fsm(self):
+ """
+ Returns a finite state machine corresponding to the sequence of calls
+ that were made from this point on.
+ """
+ # Copy the whole FSM that was generated yet
+ new_fsm = deepcopy(self.fsm)
+
+ # Generate the final state
+ state = None
+ if len(self.current_actions) != 0:
+ state = State("State" + str(self.nb_state), action_list=self.current_actions)
+ # Don't increment the nb_state here - we would break the linearity
+ # because we might generate more stuff with this creator later.
+ # Since we rely on linearity for continuity when generating the
+ # next state's name on an event filter, we cannot increment here.
+ else:
+ state = State("State" + str(self.nb_state))
+
+ # Insert the state in the copy of the FSM
+ new_fsm.add_state(state)
+
+ return new_fsm
+
diff --git a/tutorius/overlayer.py b/tutorius/overlayer.py
new file mode 100644
index 0000000..931949d
--- /dev/null
+++ b/tutorius/overlayer.py
@@ -0,0 +1,503 @@
+"""
+This module manages drawing of overlayed widgets. The class responsible for
+drawing management (Overlayer) and basic overlayable widgets are defined here.
+"""
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import gobject
+import gtk
+import cairo
+import pangocairo
+from math import pi
+
+from sugar import profile
+
+# for easy profile access from cairo
+color = profile.get_color().get_stroke_color()
+xo_line_color = (int(color[1:3], 16)/255.0,
+ int(color[3:5], 16)/255.0,
+ int(color[5:7], 16)/255.0)
+color = profile.get_color().get_fill_color()
+xo_fill_color = (int(color[1:3], 16)/255.0,
+ int(color[3:5], 16)/255.0,
+ int(color[5:7], 16)/255.0)
+del color
+
+# This is the CanvasDrawable protocol. Any widget wishing to be drawn on the
+# overlay must implement it. See TextBubble for a sample implementation.
+#class CanvasDrawable(object):
+# """Defines the CanvasDrawable protocol"""
+# no_expose = None
+# def draw_with_context(self, context):
+# """
+# Draws the cairo widget with the passed cairo context.
+# This will be called if the widget is child of an overlayer.
+# """
+# pass
+
+class Overlayer(gtk.Layout):
+ """
+ This guy manages drawing of overlayed widgets. Those can be standard GTK
+ widgets or special "cairoDrawable" widgets which support the defined
+ interface (see the put method).
+
+ @param overlayed widget to be overlayed. Will be resized to full size.
+ """
+ def __init__(self, overlayed=None):
+ gtk.Layout.__init__(self)
+
+ self._overlayed = overlayed
+ if overlayed:
+ self.put(overlayed, 0, 0)
+
+ self.__realizer = self.connect("expose-event", self.__init_realized)
+ self.connect("size-allocate", self.__size_allocate)
+ self.show()
+
+ self.__render_handle = None
+
+ def put(self, child, x, y):
+ """
+ Adds a child widget to be overlayed. This can be, overlay widgets or
+ normal GTK widgets (though normal widgets will alwas appear under
+ cairo widgets due to the rendering chain).
+
+ @param child the child to add
+ @param x the horizontal coordinate for positionning
+ @param y the vertical coordinate for positionning
+ """
+ if hasattr(child, "draw_with_context"):
+ # if the widget has the CanvasDrawable protocol, use it.
+ child.no_expose = True
+ gtk.Layout.put(self, child, x, y)
+
+ # be sure to redraw or the overlay may not show
+ self.queue_draw()
+
+
+ def __init_realized(self, widget, event):
+ """
+ Initializer to set once widget is realized.
+ Since an expose event is signaled only to realized widgets, we set this
+ callback for the first expose run. It should also be called after
+ beign reparented to ensure the window used for drawing is set up.
+ """
+ assert hasattr(self.window, "set_composited"), \
+ "compositing not supported or widget not realized."
+ self.disconnect(self.__realizer)
+ del self.__realizer
+
+ self.parent.set_app_paintable(True)
+
+ # the parent is composited, so we can access gtk's rendered buffer
+ # and overlay over. If we don't composite, we won't be able to read
+ # pixels and background will be black.
+ self.window.set_composited(True)
+ self.__render_handle = self.parent.connect_after("expose-event", \
+ self.__expose_overlay)
+
+ def __expose_overlay(self, widget, event):
+ """expose event handler to draw the thing."""
+ #get our child (in this case, the event box)
+ child = widget.get_child()
+
+ #create a cairo context to draw to the window
+ ctx = widget.window.cairo_create()
+
+ #the source data is the (composited) event box
+ ctx.set_source_pixmap(child.window,
+ child.allocation.x,
+ child.allocation.y)
+
+ #draw no more than our expose event intersects our child
+ region = gtk.gdk.region_rectangle(child.allocation)
+ rect = gtk.gdk.region_rectangle(event.area)
+ region.intersect(rect)
+ ctx.region (region)
+ ctx.clip()
+
+ ctx.set_operator(cairo.OPERATOR_OVER)
+ # has to be blended and a 1.0 alpha would not make it blend
+ ctx.paint_with_alpha(0.99)
+
+ #draw overlay
+ for drawn_child in self.get_children()[1:]:
+ if hasattr(drawn_child, "draw_with_context"):
+ drawn_child.draw_with_context(ctx)
+
+ def __size_allocate(self, widget, allocation):
+ """
+ Set size allocation (actual gtk widget size) and propagate it to
+ overlayed child
+ """
+ self.allocation = allocation
+ # One may wonder why using size_request instead of size_allocate;
+ # Since widget is laid out in a Layout box, the Layout will honor the
+ # requested size. Using size_allocate could make a nasty nested loop in
+ # some cases.
+ self._overlayed.set_size_request(allocation.width, allocation.height)
+
+
+class TextBubble(gtk.Widget):
+ """
+ A CanvasDrawableWidget drawing a round textbox and a tail pointing
+ to a specified widget.
+ """
+ def __init__(self, text, speaker=None, tailpos=[0,0]):
+ """
+ Creates a new cairo rendered text bubble.
+
+ @param text the text to render in the bubble
+ @param speaker the widget to compute the tail position from
+ @param tailpos (optional) position relative to the bubble to use as
+ the tail position, if no speaker
+ """
+ gtk.Widget.__init__(self)
+
+ # FIXME: ensure previous call does not interfere with widget stacking,
+ # as using a gtk.Layout and stacking widgets may reveal a screwed up
+ # order with the cairo widget on top.
+ self.__label = None
+
+ self.label = text
+ self.speaker = speaker
+ self.tailpos = tailpos
+ self.line_width = 5
+ self.padding = 20
+
+ self._no_expose = False
+ self.__exposer = None
+
+ def draw_with_context(self, context):
+ """
+ Draw using the passed cairo context instead of creating a new cairo
+ context. This eases blending between multiple cairo-rendered
+ widgets.
+ """
+ context.translate(self.allocation.x, self.allocation.y)
+ width = self.allocation.width
+ height = self.allocation.height
+ xradius = width/2
+ yradius = height/2
+ width -= self.line_width
+ height -= self.line_width
+ #
+ # TODO fetch speaker coordinates
+
+ # draw bubble tail if present
+ if self.tailpos != [0,0]:
+ context.move_to(xradius-width/4, yradius)
+ context.line_to(self.tailpos[0], self.tailpos[1])
+ context.line_to(xradius+width/4, yradius)
+ context.set_line_width(self.line_width)
+ context.set_source_rgb(*xo_line_color)
+ context.stroke_preserve()
+
+ # bubble border
+ context.move_to(width-self.padding, 0.0)
+ context.line_to(self.padding, 0.0)
+ context.arc_negative(self.padding, self.padding, self.padding,
+ 3*pi/2, pi)
+ context.line_to(0.0, height-self.padding)
+ context.arc_negative(self.padding, height-self.padding, self.padding,
+ pi, pi/2)
+ context.line_to(width-self.padding, height)
+ context.arc_negative(width-self.padding, height-self.padding,
+ self.padding, pi/2, 0)
+ context.line_to(width, self.padding)
+ context.arc_negative(width-self.padding, self.padding, self.padding,
+ 0.0, -pi/2)
+ context.set_line_width(self.line_width)
+ context.set_source_rgb(*xo_line_color)
+ context.stroke_preserve()
+ context.set_source_rgb(*xo_fill_color)
+ context.fill()
+
+ # bubble painting. Redrawing the inside after the tail will combine
+ if self.tailpos != [0,0]:
+ context.move_to(xradius-width/4, yradius)
+ context.line_to(self.tailpos[0], self.tailpos[1])
+ context.line_to(xradius+width/4, yradius)
+ context.set_line_width(self.line_width)
+ context.set_source_rgb(*xo_fill_color)
+ context.fill()
+
+ context.set_source_rgb(1.0, 1.0, 1.0)
+ pangoctx = pangocairo.CairoContext(context)
+ self._text_layout.set_markup(self.__label)
+ text_size = self._text_layout.get_pixel_size()
+ pangoctx.move_to(
+ int((self.allocation.width-text_size[0])/2),
+ int((self.allocation.height-text_size[1])/2))
+ pangoctx.show_layout(self._text_layout)
+
+ # work done. Be kind to next cairo widgets and reset matrix.
+ context.identity_matrix()
+
+ def do_realize(self):
+ """ Setup gdk window creation. """
+ self.set_flags(gtk.REALIZED | gtk.NO_WINDOW)
+ self.window = self.get_parent_window()
+ if not self._no_expose:
+ self.__exposer = self.connect_after("expose-event", \
+ self.__on_expose)
+
+ def __on_expose(self, widget, event):
+ """Redraw event callback."""
+ ctx = self.window.cairo_create()
+
+ self.draw_with_context(ctx)
+
+ return True
+
+ def _set_label(self, value):
+ """Sets the label and flags the widget to be redrawn."""
+ self.__label = "<b>%s</b>"%value
+ if not self.parent:
+ return
+ ctx = self.parent.window.cairo_create()
+ pangoctx = pangocairo.CairoContext(ctx)
+ self._text_layout = pangoctx.create_layout()
+ self._text_layout.set_markup(value)
+ del pangoctx, ctx#, surf
+
+ def do_size_request(self, requisition):
+ """Fill requisition with size occupied by the widget."""
+ ctx = self.parent.window.cairo_create()
+ pangoctx = pangocairo.CairoContext(ctx)
+ self._text_layout = pangoctx.create_layout()
+ self._text_layout.set_markup(self.__label)
+
+ width, height = self._text_layout.get_pixel_size()
+ requisition.width = int(width+2*self.padding)
+ requisition.height = int(height+2*self.padding)
+
+ def do_size_allocate(self, allocation):
+ """Save zone allocated to the widget."""
+ self.allocation = allocation
+
+ def _get_label(self):
+ """Getter method for the label property"""
+ return self.__label[3:-4]
+
+ def _set_no_expose(self, value):
+ """setter for no_expose property"""
+ self._no_expose = value
+ if not (self.flags() and gtk.REALIZED):
+ return
+
+ if self.__exposer and value:
+ self.parent.disconnect(self.__exposer)
+ self.__exposer = None
+ elif (not self.__exposer) and (not value):
+ self.__exposer = self.parent.connect_after("expose-event",
+ self.__on_expose)
+
+ def _get_no_expose(self):
+ """getter for no_expose property"""
+ return self._no_expose
+
+ no_expose = property(fset=_set_no_expose, fget=_get_no_expose,
+ doc="Whether the widget should handle exposition events or not.")
+
+ label = property(fget=_get_label, fset=_set_label,
+ doc="Text label which is to be painted on the top of the widget")
+
+gobject.type_register(TextBubble)
+
+class Rectangle(gtk.Widget):
+ """
+ A CanvasDrawableWidget drawing a rectangle over a specified widget.
+ """
+ def __init__(self, widget, color):
+ """
+ Creates a new Rectangle
+
+ @param widget the widget to cover
+ @param color the color of the rectangle, as a 4-tuple RGBA
+ """
+ gtk.Widget.__init__(self)
+
+ self.covered = widget
+ self.color = color
+
+ self.__exposer = self.connect("expose-event", self.__on_expose)
+
+ def draw_with_context(self, context):
+ """
+ Draw using the passed cairo context instead of creating a new cairo
+ context. This eases blending between multiple cairo-rendered
+ widgets.
+ """
+ if self.covered is None:
+ # nothing to hide, no coordinates, no drawing
+ return
+ mask_alloc = self.covered.get_allocation()
+ x, y = self.covered.translate_coordinates(self.parent, 0, 0)
+
+ context.rectangle(x, y, mask_alloc.width, mask_alloc.height)
+ context.set_source_rgba(*self.color)
+ context.fill()
+
+ def do_realize(self):
+ """ Setup gdk window creation. """
+ self.set_flags(gtk.REALIZED | gtk.NO_WINDOW)
+
+ self.window = self.get_parent_window()
+ if not isinstance(self.parent, Overlayer):
+ assert False, "%s should not realize" % type(self).__name__
+ print "Danger, Will Robinson! Rectangle parent is not Overlayer"
+
+ def __on_expose(self, widget, event):
+ """Redraw event callback."""
+ assert False, "%s wasn't meant to be exposed by gtk" % \
+ type(self).__name__
+ ctx = self.window.cairo_create()
+
+ self.draw_with_context(ctx)
+
+ return True
+
+ def do_size_request(self, requisition):
+ """Fill requisition with size occupied by the masked widget."""
+ # This is a bit pointless, as this will always ignore allocation and
+ # be rendered directly on overlay, but for sanity, let's put some values
+ # in there.
+ if not self.covered:
+ requisition.width = 0
+ requisition.height = 0
+ return
+
+ masked_alloc = self.covered.get_allocation()
+ requisition.width = masked_alloc.width
+ requisition.height = masked_alloc.height
+
+ def do_size_allocate(self, allocation):
+ """Save zone allocated to the widget."""
+ self.allocation = allocation
+
+ def _set_no_expose(self, value):
+ """setter for no_expose property"""
+ if self.__exposer and value:
+ self.disconnect(self.__exposer)
+ self.__exposer = None
+ elif (not self.__exposer) and (not value):
+ self.__exposer = self.connect("expose-event", self.__on_expose)
+
+ def _get_no_expose(self):
+ """getter for no_expose property"""
+ return not self.__exposer
+
+ no_expose = property(fset=_set_no_expose, fget=_get_no_expose,
+ doc="Whether the widget should handle exposition events or not.")
+gobject.type_register(Rectangle)
+
+class Mask(gtk.EventBox):
+ """
+ A CanvasDrawableWidget drawing a rectangle over a specified widget.
+ """
+ def __init__(self, catch_events=False, pass_thru=()):
+ """
+ Creates a new Rectangle
+
+ @param catch_events whether the Mask should catch events
+ @param pass_thru the widgets that "punch holes" through this Mask.
+ Events will pass through to those widgets.
+ """
+ gtk.EventBox.__init__(self)
+ self.no_expose = True # ignored
+ self._catch_events = False
+ self.catch_events = catch_events
+ self.pass_thru = list(pass_thru)
+
+ def __del__(self):
+ for widget in self.pass_thru:
+ widget.drag_unhighlight()
+
+ def mask(self, widget):
+ """
+ Remove the widget from the unmasked list.
+ @param widget a widget to remask
+ """
+ assert widget in self.pass_thru, \
+ "trying to mask already masked widget"
+ self.pass_thru.remove(widget)
+ widget.drag_unhighlight()
+
+ def unmask(self, widget):
+ """
+ Add to the unmasked list the widget passed.
+ A hole will be punched through the mask at that widget's position.
+ @param widget a widget to unmask
+ """
+ if widget not in self.pass_thru:
+ widget.drag_highlight()
+ self.pass_thru.append(widget)
+
+
+ def set_catch_events(self, do_catch):
+ """Sets whether the mask catches events of widgets under it"""
+ if bool(self._catch_events) ^ bool(do_catch):
+ if do_catch:
+ self._catch_events = True
+ self.grab_add()
+ else:
+ self.grab_remove()
+ self._catch_events = False
+
+ def get_catch_events(self):
+ """Gets whether the mask catches events of widgets under it"""
+ return bool(self._catch_handle)
+
+ catch_events = property(fset=set_catch_events, fget=get_catch_events)
+
+ def draw_with_context(self, context):
+ """
+ Draw using the passed cairo context instead of creating a new cairo
+ context. This eases blending between multiple cairo-rendered
+ widgets.
+ """
+ # Fill parent container
+ mask_alloc = self.parent.get_allocation()
+ oldrule = context.get_fill_rule()
+ context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
+ x, y = self.translate_coordinates(self.parent, 0, 0)
+
+ context.rectangle(x, y, mask_alloc.width, mask_alloc.height)
+ for hole in self.pass_thru:
+ alloc = hole.get_allocation()
+ x, y = hole.translate_coordinates(self.parent, 0, 0)
+ context.rectangle(x, y, alloc.width, alloc.height)
+ context.set_source_rgba(0, 0, 0, 0.7)
+ context.fill()
+ context.set_fill_rule(oldrule)
+
+ def do_size_request(self, requisition):
+ """Fill requisition with size occupied by the masked widget."""
+ # This is required for the event box to span across all the parent.
+ alloc = self.parent.get_allocation()
+ requisition.width = alloc.width
+ requisition.height = alloc.height
+
+ def do_size_allocate(self, allocation):
+ """Save zone allocated to the widget."""
+ self.allocation = allocation
+
+gobject.type_register(Mask)
+
+
+# vim:set ts=4 sts=4 sw=4 et:
diff --git a/tutorius/properties.py b/tutorius/properties.py
new file mode 100644
index 0000000..34b508a
--- /dev/null
+++ b/tutorius/properties.py
@@ -0,0 +1,323 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+This module contains properties class that can be included in other types.
+TutoriusProperties have the same behaviour as python properties (assuming you
+also use the TPropContainer), with the added benefit of having builtin dialog
+prompts and constraint validation.
+"""
+
+from sugar.tutorius.constraints import Constraint, \
+ UpperLimitConstraint, LowerLimitConstraint, \
+ MaxSizeConstraint, MinSizeConstraint, \
+ ColorConstraint, FileConstraint, BooleanConstraint, EnumConstraint
+
+class TPropContainer(object):
+ """
+ A class containing properties. This does the attribute wrapping between
+ the container instance and the property value. As properties are on the
+ containing classes, they allow static introspection of those types
+ at the cost of needing a mapping between container instances, and
+ property values. This is what TPropContainer does.
+ """
+ def __init__(self):
+ """
+ Prepares the instance for property value storage. This is done at
+ object initialization, thus allowing initial mapping of properties
+ declared on the class. Properties won't work correctly without
+ this call.
+ """
+ # create property value storage
+ object.__setattr__(self, "_props", {})
+ for attr_name in dir(type(self)):
+ propinstance = object.__getattribute__(self, attr_name)
+ if isinstance(propinstance, TutoriusProperty):
+ # only care about TutoriusProperty instances
+ propinstance.tname = attr_name
+ self._props[attr_name] = propinstance.validate(
+ propinstance.default)
+
+ def __getattribute__(self, name):
+ """
+ Process the 'fake' read of properties in the appropriate instance
+ container. Pass 'real' attributes as usual.
+ """
+ try:
+ props = object.__getattribute__(self, "_props")
+ except AttributeError:
+ # necessary for deepcopy as order of init can't be guaranteed
+ object.__setattr__(self, "_props", {})
+ props = object.__getattribute__(self, "_props")
+
+ try:
+ # try gettin value from property storage
+ # if it's not in the map, it's not a property or its default wasn't
+ # set at initialization.
+ return props[name]
+ except KeyError:
+ return object.__getattribute__(self, name)
+
+ def __setattr__(self, name, value):
+ """
+ Process the 'fake' write of properties in the appropriate instance
+ container. Pass 'real' attributes as usual.
+
+ @param name the name of the property
+ @param value the value to assign to name
+ @return the setted value
+ """
+ props = object.__getattribute__(self, "_props")
+ try:
+ # We attempt to get the property object with __getattribute__
+ # to work through inheritance and benefit of the MRO.
+ return props.__setitem__(name,
+ object.__getattribute__(self, name).validate(value))
+ except AttributeError:
+ return object.__setattr__(self, name, value)
+
+ def get_properties(self):
+ """
+ Return the list of property names.
+ """
+ return object.__getattribute__(self, "_props").keys()
+
+class TutoriusProperty(object):
+ """
+ The base class for all actions' properties. The interface is the following :
+
+ value : the value of the property
+
+ type : the type of the property
+
+ get_contraints() : the constraints inserted on this property. They define
+ what is acceptable or not as values.
+ """
+ def __init__(self):
+ super(TutoriusProperty, self).__init__()
+ self.type = None
+ self._constraints = None
+ self.default = None
+
+ def get_constraints(self):
+ """
+ Returns the list of constraints associated to this property.
+ """
+ if self._constraints is None:
+ self._constraints = []
+ for i in dir(self):
+ typ = getattr(self, i)
+ if isinstance(typ, Constraint):
+ self._constraints.append(i)
+ return self._constraints
+
+ def validate(self, value):
+ """
+ Validates the value of the property. If the value does not respect
+ the constraints on the property, this method will raise an exception.
+
+ The exception should be of the type related to the constraint that
+ failed. E.g. When a int is to be set with a value that
+ """
+ for constraint_name in self.get_constraints():
+ constraint = getattr(self, constraint_name)
+ constraint.validate(value)
+ return value
+
+class TAddonListProperty(TutoriusProperty):
+ """
+ Stores an addon component list as a property.
+ The purpose of this class is to allow correct mapping of properties
+ through encapsulated hierarchies.
+ """
+ pass
+
+
+ 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
+ limit.
+ """
+
+ def __init__(self, value, lower_limit=None, upper_limit=None):
+ TutoriusProperty.__init__(self)
+ self.type = "int"
+ self.upper_limit = UpperLimitConstraint(upper_limit)
+ self.lower_limit = LowerLimitConstraint(lower_limit)
+
+ self.default = self.validate(value)
+
+class TFloatProperty(TutoriusProperty):
+ """
+ Represents a floating point number. Can have an upper value limit and/or
+ a lower value limit.
+ """
+ def __init__(self, value, lower_limit=None, upper_limit=None):
+ TutoriusProperty.__init__(self)
+ self.type = "float"
+
+ self.upper_limit = UpperLimitConstraint(upper_limit)
+ self.lower_limit = LowerLimitConstraint(lower_limit)
+
+ self.default = self.validate(value)
+
+class TStringProperty(TutoriusProperty):
+ """
+ Represents a string. Can have a maximum size limit.
+ """
+ def __init__(self, value, size_limit=None):
+ TutoriusProperty.__init__(self)
+ self.type = "string"
+ self.size_limit = MaxSizeConstraint(size_limit)
+
+ self.default = self.validate(value)
+
+class TArrayProperty(TutoriusProperty):
+ """
+ Represents an array of properties. Can have a maximum number of element
+ limit, but there are no constraints on the content of the array.
+ """
+ def __init__(self, value, min_size_limit=None, max_size_limit=None):
+ TutoriusProperty.__init__(self)
+ self.type = "array"
+ self.max_size_limit = MaxSizeConstraint(max_size_limit)
+ self.min_size_limit = MinSizeConstraint(min_size_limit)
+ self.default = self.validate(value)
+
+class TColorProperty(TutoriusProperty):
+ """
+ Represents a RGB color with 3 8-bit integer values.
+
+ The value of the property is the array [R, G, B]
+ """
+ def __init__(self, red=None, green=None, blue=None):
+ TutoriusProperty.__init__(self)
+ self.type = "color"
+
+ self.color_constraint = ColorConstraint()
+
+ self._red = red or 0
+ self._green = green or 0
+ self._blue = blue or 0
+
+ self.default = self.validate([self._red, self._green, self._blue])
+
+class TFileProperty(TutoriusProperty):
+ """
+ Represents a path to a file on the disk.
+ """
+ def __init__(self, path):
+ """
+ Defines the path to an existing file on disk file.
+
+ For now, the path may be relative or absolute, as long as it exists on
+ the local machine.
+ TODO : Make sure that we have a file scheme that supports distribution
+ on other computers (LP 355197)
+ """
+ TutoriusProperty.__init__(self)
+
+ self.type = "file"
+
+ self.file_constraint = FileConstraint()
+
+ self.default = self.validate(path)
+
+class TEnumProperty(TutoriusProperty):
+ """
+ Represents a value in a given enumeration. This means that the value will
+ always be one in the enumeration and nothing else.
+
+ """
+ def __init__(self, value, accepted_values):
+ """
+ Creates the enumeration property.
+
+ @param value The initial value of the enum. Must be part of
+ accepted_values
+ @param accepted_values A list of values that the property can take
+ """
+ TutoriusProperty.__init__(self)
+
+ self.type = "enum"
+
+ self.enum_constraint = EnumConstraint(accepted_values)
+
+ self.default = self.validate(value)
+
+class TBooleanProperty(TutoriusProperty):
+ """
+ Represents a True or False value.
+ """
+ def __init__(self, value=False):
+ TutoriusProperty.__init__(self)
+
+ self.type = "boolean"
+
+ self.boolean_constraint = BooleanConstraint()
+
+ self.default = self.validate(value)
+
+class TUAMProperty(TutoriusProperty):
+ """
+ Represents a widget of the interface by storing its UAM.
+ """
+ # TODO : Pending UAM check-in (LP 355199)
+ pass
+
+class TAddonProperty(TutoriusProperty):
+ """
+ Reprensents an embedded tutorius Addon Component (action, trigger, etc.)
+ The purpose of this class is to flag the container for proper
+ serialization, as the contained object can't be directly dumped to text
+ for its attributes to be saved.
+ """
+ class NullAction(TPropContainer):
+ def do(self): pass
+ def undo(self): pass
+
+ def __init__(self):
+ super(TAddonProperty, self).__init__()
+ self.type = "addon"
+ self.default = self.NullAction()
+
+ def validate(self, value):
+ if isinstance(value, TPropContainer):
+ return super(TAddonProperty, self).validate(value)
+ raise ValueError("Expected TPropContainer instance as TaddonProperty value")
+
+class TAddonListProperty(TutoriusProperty):
+ """
+ Reprensents an embedded tutorius Addon List Component.
+ See TAddonProperty
+ """
+ def __init__(self):
+ super(TAddonProperty, self).__init__()
+ self.type = "addonlist"
+ self.default = []
+
+
diff --git a/tutorius/services.py b/tutorius/services.py
new file mode 100644
index 0000000..9ed2e50
--- /dev/null
+++ b/tutorius/services.py
@@ -0,0 +1,69 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+"""
+Services
+
+This module supplies services to be used by States, FSMs, Actions and Filters.
+
+Services provided are:
+-Access to the running activity
+-Access to the running tutorial
+"""
+
+
+class ObjectStore(object):
+ #Begin Singleton code
+ instance=None
+ def __new__(cls):
+ if not ObjectStore.instance:
+ ObjectStore.instance = ObjectStore.__ObjectStore()
+
+ return ObjectStore.instance
+
+ #End Singleton code
+ class __ObjectStore(object):
+ """
+ The Object Store is a singleton class that allows access to
+ the current runnign activity and tutorial.
+ """
+ def __init__(self):
+ self._activity = None
+ self._tutorial = None
+ #self._fsm_path = []
+
+ def set_activity(self, activity):
+ """Setter for activity"""
+ self._activity = activity
+
+ def get_activity(self):
+ """Getter for activity"""
+ return self._activity
+
+ activity = property(fset=set_activity,fget=get_activity,doc="activity")
+
+ def set_tutorial(self, tutorial):
+ """Setter for tutorial"""
+ self._tutorial = tutorial
+
+ def get_tutorial(self):
+ """Getter for tutorial"""
+ return self._tutorial
+
+ tutorial = property(fset=set_tutorial,fget=get_tutorial,doc="tutorial")
+
+ __doc__ = __ObjectStore.__doc__
+
diff --git a/tutorius/testwin.py b/tutorius/testwin.py
new file mode 100644
index 0000000..ef92b7f
--- /dev/null
+++ b/tutorius/testwin.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import gtk
+import dragbox
+import textbubble
+
+box = None
+
+def _destroy(widget, data=None):
+ gtk.main_quit()
+
+def _delete_event(widget, event, data=None):
+ print "quitting"
+ return False
+
+def blublu(widget, data=""):
+ print data
+
+def _drag_toggle(widget, data=None):
+ global box
+ box.dragMode = not box.dragMode
+
+
+def addBtn(widget, data, bubble=0, btns=[0]):
+ if bubble == 1:
+ bt = textbubble.TextBubble("Bubble(%d)"%btns[0])
+ else:
+ bt = gtk.Button("Bubble(%d)"%btns[0])
+ ##bt.set_size_request(60,40)
+ bt.show()
+ data.attach(bt)
+ btns[0] += 1
+
+def main():
+ global box
+ win = gtk.Window(type=gtk.WINDOW_TOPLEVEL)
+ win.connect("delete_event", _delete_event)
+ win.connect("destroy", _destroy)
+
+ win.set_default_size(800,600)
+
+ vbox = gtk.VBox()
+ vbox.show()
+ win.add(vbox)
+
+ check = gtk.CheckButton(label="dragMode")
+ check.connect("toggled", _drag_toggle)
+ check.show()
+ vbox.pack_start(check, expand=False)
+
+ btnadd = gtk.Button("Add Bubble")
+ btnadd.show()
+ vbox.pack_start(btnadd, expand=False)
+ btnadd2 = gtk.Button("Add Button")
+ btnadd2.show()
+ vbox.pack_start(btnadd2, expand=False)
+
+## bubble = textbubble.TextBubble("Bubbles!")
+## bubble.show()
+## bubble.set_size_request(40,40)
+## vbox.pack_start(bubble, expand=False)
+
+ box = dragbox.DragBox()
+ box.set_border_width(10)
+ box.show()
+ vbox.pack_start(box, expand=True, fill=True)
+
+ btnadd.connect("clicked", addBtn, box, 1)
+ btnadd2.connect("clicked", addBtn, box)
+
+ win.show()
+ gtk.main()
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/tutorius/textbubble.py b/tutorius/textbubble.py
new file mode 100644
index 0000000..e09b298
--- /dev/null
+++ b/tutorius/textbubble.py
@@ -0,0 +1,109 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+This module represents TextBubble widget. Also, it aims to be a short example
+of drawing with Cairo.
+"""
+
+import gtk
+from math import pi as M_PI
+import cairo
+
+# FIXME set as subclass of gtk.Widget, not EventBox
+class TextBubble(gtk.EventBox):
+ def __init__(self, label):
+ gtk.EventBox.__init__(self)
+
+ ##self.set_app_paintable(True) # else may be blank
+ # FIXME ensure previous call does not interfere with widget stacking
+ self.label = label
+ self.lineWidth = 5
+
+ self.connect("expose-event", self._on_expose)
+
+ def __draw_with_cairo__(self, context):
+ """
+
+ """
+ pass
+
+ def _on_expose(self, widget, event):
+ """Redraw event callback."""
+ # TODO
+ ctx = self.window.cairo_create()
+
+ # set drawing region. Useless since this widget has its own window.
+ ##region = gtk.gdk.region_rectangle(self.allocation)
+ ##region.intersect(gtk.gdk.region_rectangle(event.area))
+ ##ctx.region(region)
+ ##ctx.rectangle(event.area.x, event.area.y, event.area.width, event.area.height)
+ ##ctx.clip()
+
+ ##import pdb; pdb.set_trace()
+ ##ctx.set_operator(cairo.OPERATOR_CLEAR)
+ ##ctx.paint()
+ ##ctx.set_operator(cairo.OPERATOR_OVER)
+
+ width = self.allocation.width
+ height = self.allocation.height
+ xradius = width/2
+ yradius = height/2
+ width -= self.lineWidth
+ height -= self.lineWidth
+ ctx.move_to(self.lineWidth, yradius)
+ ctx.curve_to(self.lineWidth, self.lineWidth,
+ self.lineWidth, self.lineWidth, xradius, self.lineWidth)
+ ctx.curve_to(width, self.lineWidth,
+ width, self.lineWidth, width, yradius)
+ ctx.curve_to(width, height, width, height, xradius, height)
+ ctx.curve_to(self.lineWidth, height,
+ self.lineWidth, height, self.lineWidth, yradius)
+ ctx.set_source_rgb(1.0, 1.0, 1.0)
+ ctx.fill_preserve()
+ ctx.set_line_width(self.lineWidth)
+ ctx.set_source_rgb(0.0, 0.0, 0.0)
+ ctx.stroke()
+
+ _, _, textWidth, textHeight, _, _ = ctx.text_extents(self._label)
+ ctx.move_to(int((self.allocation.width-textWidth)/2),
+ int((self.allocation.height+textHeight)/2))
+ ctx.text_path(self._label)
+ ctx.fill()
+
+ return True
+
+
+ def _set_label(self, value):
+ """Sets the label and flags the widget to be redrawn."""
+ self._label = value
+ # FIXME hack to calculate size. necessary because may not have been
+ # realized
+ surf = cairo.SVGSurface("/dev/null", 0, 0)
+ ctx = cairo.Context(surf)
+ _, _, width, height, _, _ = ctx.text_extents(self._label)
+ del ctx, surf
+
+ # FIXME bogus values follows
+ self.set_size_request(int(width+20), int(height+40))
+ # TODO test changing a realized label
+
+ def _get_label(self):
+ """Getter method for the label property"""
+ return self._label
+
+ label = property(fget=_get_label, fset=_set_label,\
+ doc="Text label which is to be painted on the top of the widget")
+
diff --git a/tutorius/uam/Makefile.am b/tutorius/uam/Makefile.am
new file mode 100644
index 0000000..219291e
--- /dev/null
+++ b/tutorius/uam/Makefile.am
@@ -0,0 +1,5 @@
+sugardir = $(pythondir)/sugar/tutorius/uam
+sugar_PYTHON = \
+ gobjectparser.py \
+ gtkparser.py \
+ __init__.py
diff --git a/tutorius/uam/__init__.py b/tutorius/uam/__init__.py
new file mode 100644
index 0000000..7cf5671
--- /dev/null
+++ b/tutorius/uam/__init__.py
@@ -0,0 +1,88 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+"""
+Universal Addressing Mechanism module
+
+Allows addressing Events, signals, widgets, etc for supported platforms
+"""
+
+from urllib2 import urlparse
+
+import gtkparser
+import gobjectparser
+
+
+SCHEME="tap" #Tutorius Adressing Protocol
+
+__parsers = {
+ gtkparser.SCHEME:gtkparser.parse_gtk,
+ gobjectparser.SCHEME:gobjectparser.parse_gobject,
+}
+
+def __add_to_urlparse(name):
+ #Add to uses_netloc
+ if not name in urlparse.uses_netloc:
+ urlparse.uses_netloc.append(name)
+
+ #Add to uses_relative
+ if not name in urlparse.uses_relative:
+ urlparse.uses_relative.append(name)
+
+# #Add to uses_params
+# if not name in urlparse.uses_params:
+# urlparse.uses_params.append(name)
+
+ #Add to uses_query
+ if not name in urlparse.uses_query:
+ urlparse.uses_query.append(name)
+
+ #Add to uses_frament
+ if not name in urlparse.uses_fragment:
+ urlparse.uses_fragment.append(name)
+
+
+#Add schemes to urlparse
+__add_to_urlparse(SCHEME)
+
+for subscheme in [".".join([SCHEME,s]) for s in __parsers]:
+ __add_to_urlparse(subscheme)
+
+
+class SchemeError(Exception):
+ def __init__(self, message):
+ Exception.__init__(self, message)
+ self.message = message
+
+
+def parse_uri(uri):
+ res = urlparse.urlparse(uri)
+
+ scheme = res.scheme.split(".")[0]
+ subscheme = ".".join(res.scheme.split(".")[1:])
+ if not scheme == SCHEME:
+ raise SchemeError("Scheme %s not supported" % scheme)
+
+ if subscheme != "" and not subscheme in __parsers:
+ raise SchemeError("SubScheme %s not supported" % subscheme)
+
+ if subscheme:
+ return __parsers[subscheme](res)
+
+ return res
+
+
+
diff --git a/tutorius/uam/gobjectparser.py b/tutorius/uam/gobjectparser.py
new file mode 100644
index 0000000..c1fba3d
--- /dev/null
+++ b/tutorius/uam/gobjectparser.py
@@ -0,0 +1,27 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+"""
+UAM Parser for gobject subscheme
+
+To be completed
+"""
+
+SCHEME="gobject"
+
+def parse_gobject(parsed_uri):
+ """Do nothing for now"""
+ return parsed_uri
diff --git a/tutorius/uam/gtkparser.py b/tutorius/uam/gtkparser.py
new file mode 100644
index 0000000..ede2f03
--- /dev/null
+++ b/tutorius/uam/gtkparser.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Vincent Vinet <vince.vinet@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
+"""
+UAM Parser for gtk subscheme
+
+Allows addressing Gtk Events, signals, widgets
+
+The gtk subscheme for tutorius is
+
+<scheme>://<activity>/<path>[?<params>#<ptype>]
+
+where:
+
+<scheme> is the uam.SCHEME + "." + SCHEME
+
+<activity> is the activity's dns identifier, such as battleship.tutorius.org
+
+<path> is the Hierarchical path to the widget, where 0 is the activity, such as /0/0/1/0/1/0
+
+<params> can be used to specify additionnal parameters required for an event handler or action, such as event=clicked
+
+<ptype> must be used with params to specify which action or eventfilter to use, such as "DialogMessage"
+
+"""
+
+SCHEME="gtk"
+
+def parse_gtk(parsed_uri):
+ """Do nothing for now"""
+ return parsed_uri