diff options
Diffstat (limited to 'tutorius')
-rw-r--r-- | tutorius/TProbe.py | 30 | ||||
-rw-r--r-- | tutorius/actions.py | 7 | ||||
-rw-r--r-- | tutorius/addon.py | 14 | ||||
-rw-r--r-- | tutorius/constraints.py | 22 | ||||
-rw-r--r-- | tutorius/core.py | 13 | ||||
-rw-r--r-- | tutorius/creator.py | 646 | ||||
-rw-r--r-- | tutorius/editor.py | 2 | ||||
-rw-r--r-- | tutorius/engine.py | 4 | ||||
-rw-r--r-- | tutorius/filters.py | 2 | ||||
-rw-r--r-- | tutorius/linear_creator.py | 8 | ||||
-rw-r--r-- | tutorius/overlayer.py | 4 | ||||
-rw-r--r-- | tutorius/properties.py | 22 | ||||
-rw-r--r-- | tutorius/service.py | 4 | ||||
-rw-r--r-- | tutorius/store.py | 18 | ||||
-rw-r--r-- | tutorius/tutorial.py | 806 | ||||
-rw-r--r-- | tutorius/vault.py | 8 | ||||
-rw-r--r-- | tutorius/viewer.py | 423 |
17 files changed, 1808 insertions, 225 deletions
diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py index e18ed67..f55547c 100644 --- a/tutorius/TProbe.py +++ b/tutorius/TProbe.py @@ -8,12 +8,12 @@ import dbus import dbus.service import cPickle as pickle -import sugar.tutorius.addon as addon -from sugar.tutorius.services import ObjectStore -from sugar.tutorius.properties import TPropContainer +from . import addon +from .services import ObjectStore +from .properties import TPropContainer -from sugar.tutorius.dbustools import remote_call, save_args +from .dbustools import remote_call, save_args import copy """ @@ -195,7 +195,11 @@ class TProbe(dbus.service.Object): # The actual method we will call on the probe to send events def notify(self, event): LOGGER.debug("TProbe :: notify event %s", str(event)) - self.eventOccured(pickle.dumps(event)) + #Check that this event is even allowed + if event in self._subscribedEvents.values(): + self.eventOccured(pickle.dumps(event)) + else: + raise RuntimeWarning("Attempted to raise an unregistered event") # Return a unique name for this action def _generate_action_reference(self, action): @@ -400,8 +404,8 @@ class ProbeProxy: Detach the ProbeProxy from it's TProbe. All installed actions and subscribed events should be removed. """ - for action in self._actions.keys(): - self.uninstall(action, block) + for action_addr in self._actions.keys(): + self.uninstall(action_addr, block) for address in self._subscribedEvents.keys(): self.unsubscribe(address, block) @@ -414,7 +418,13 @@ class ProbeManager(object): For now, it only handles one at a time, though. Actually it doesn't do much at all. But it keeps your encapsulation happy """ - def __init__(self): + def __init__(self, proxy_class=ProbeProxy): + """Constructor + @param proxy_class Class to use for creating Proxies to activities. + The class should support the same interface as ProbeProxy. Exists + to make this class unit-testable by replacing the Proxy with a mock + """ + self._ProxyClass = proxy_class self._probes = {} self._current_activity = None @@ -431,7 +441,7 @@ class ProbeManager(object): if activity_id in self._probes: raise RuntimeWarning("Activity already attached") - self._probes[activity_id] = ProbeProxy(activity_id) + self._probes[activity_id] = self._ProxyClass(activity_id) #TODO what do we do with this? Raise something? if self._probes[activity_id].isAlive(): print "Alive!" @@ -442,6 +452,8 @@ class ProbeManager(object): if activity_id in self._probes: probe = self._probes.pop(activity_id) probe.detach() + if self._current_activity == activity_id: + self._current_activity = None def install(self, action, block=False): """ diff --git a/tutorius/actions.py b/tutorius/actions.py index 08f55cd..bb15459 100644 --- a/tutorius/actions.py +++ b/tutorius/actions.py @@ -20,11 +20,12 @@ import gtk from gettext import gettext as _ -from sugar.tutorius import addon -from sugar.tutorius.services import ObjectStore -from sugar.tutorius.properties import * from sugar.graphics import icon +from . import addon +from .services import ObjectStore +from .properties import * + class DragWrapper(object): """Wrapper to allow gtk widgets to be dragged around""" def __init__(self, widget, position, draggable=False): diff --git a/tutorius/addon.py b/tutorius/addon.py index 15612c8..7ac68f7 100644 --- a/tutorius/addon.py +++ b/tutorius/addon.py @@ -38,6 +38,9 @@ import logging PREFIX = __name__+"s" PATH = re.sub("addon\\.py[c]$", "", __file__)+"addons" +TYPE_ACTION = 'action' +TYPE_EVENT = 'event' + _cache = None def _reload_addons(): @@ -47,9 +50,11 @@ def _reload_addons(): mod = __import__(PREFIX+'.'+re.sub("\\.py$", "", addon), {}, {}, [""]) if hasattr(mod, "__action__"): _cache[mod.__action__['name']] = mod.__action__ + mod.__action__['type'] = TYPE_ACTION continue if hasattr(mod, "__event__"): _cache[mod.__event__['name']] = mod.__event__ + mod.__event__['type'] = TYPE_EVENT def create(name, *args, **kwargs): global _cache @@ -78,4 +83,13 @@ def get_addon_meta(name): _reload_addons() return _cache[name] +def get_name_from_type(typ): + global _cache + if not _cache: + _reload_addons() + for addon in _cache.keys(): + if typ == _cache[addon]['class']: + return addon + return None + # vim:set ts=4 sts=4 sw=4 et: diff --git a/tutorius/constraints.py b/tutorius/constraints.py index e91f23a..519bce8 100644 --- a/tutorius/constraints.py +++ b/tutorius/constraints.py @@ -25,6 +25,12 @@ for some properties. # For the File Constraint import os +class ConstraintException(Exception): + """ + Parent class for all constraint exceptions + """ + pass + class Constraint(): """ Basic block for defining constraints on a TutoriusProperty. Every class @@ -47,7 +53,7 @@ class ValueConstraint(Constraint): def __init__(self, limit): self.limit = limit -class UpperLimitConstraintError(Exception): +class UpperLimitConstraintError(ConstraintException): pass class UpperLimitConstraint(ValueConstraint): @@ -64,7 +70,7 @@ class UpperLimitConstraint(ValueConstraint): raise UpperLimitConstraintError() return -class LowerLimitConstraintError(Exception): +class LowerLimitConstraintError(ConstraintException): pass class LowerLimitConstraint(ValueConstraint): @@ -81,7 +87,7 @@ class LowerLimitConstraint(ValueConstraint): raise LowerLimitConstraintError() return -class MaxSizeConstraintError(Exception): +class MaxSizeConstraintError(ConstraintException): pass class MaxSizeConstraint(ValueConstraint): @@ -99,7 +105,7 @@ class MaxSizeConstraint(ValueConstraint): raise MaxSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit)) return -class MinSizeConstraintError(Exception): +class MinSizeConstraintError(ConstraintException): pass class MinSizeConstraint(ValueConstraint): @@ -117,7 +123,7 @@ class MinSizeConstraint(ValueConstraint): raise MinSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit)) return -class ColorConstraintError(Exception): +class ColorConstraintError(ConstraintException): pass class ColorArraySizeError(ColorConstraintError): @@ -153,7 +159,7 @@ class ColorConstraint(Constraint): return -class BooleanConstraintError(Exception): +class BooleanConstraintError(ConstraintException): pass class BooleanConstraint(Constraint): @@ -165,7 +171,7 @@ class BooleanConstraint(Constraint): return raise BooleanConstraintError("Value is not True or False") -class EnumConstraintError(Exception): +class EnumConstraintError(ConstraintException): pass class EnumConstraint(Constraint): @@ -190,7 +196,7 @@ class EnumConstraint(Constraint): raise EnumConstraintError("Value is not part of the enumeration") return -class FileConstraintError(Exception): +class FileConstraintError(ConstraintException): pass class FileConstraint(Constraint): diff --git a/tutorius/core.py b/tutorius/core.py index b24b80b..bfbe07b 100644 --- a/tutorius/core.py +++ b/tutorius/core.py @@ -24,9 +24,9 @@ This module contains the core classes for tutorius import logging import os -from sugar.tutorius.TProbe import ProbeManager -from sugar.tutorius.dbustools import save_args -from sugar.tutorius import addon +from .TProbe import ProbeManager +from .dbustools import save_args +from . import addon logger = logging.getLogger("tutorius") @@ -505,10 +505,9 @@ class FiniteStateMachine(State): #TODO : Move this code inside the State itself - we're breaking # encap :P - if st._transitions: - for event, state in st._transitions.items(): - if state == state_name: - del st._transitions[event] + for event in st._transitions: + if st._transitions[event] == state_name: + del st._transitions[event] # Remove the state from the dictionary del self._states[state_name] diff --git a/tutorius/creator.py b/tutorius/creator.py index d56fc72..c477056 100644 --- a/tutorius/creator.py +++ b/tutorius/creator.py @@ -22,16 +22,19 @@ the activity itself. # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import gtk.gdk +import gtk.glade import gobject from gettext import gettext as T -from sugar.graphics.toolbutton import ToolButton +import os +from sugar.graphics import icon +import copy -from sugar.tutorius import overlayer, gtkutils, actions, vault, 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 +from . import overlayer, gtkutils, actions, vault, properties, addon +from . import filters +from .services import ObjectStore +from .core import Tutorial, FiniteStateMachine, State +from . import viewer class Creator(object): """ @@ -47,80 +50,162 @@ class Creator(object): """ self._activity = activity if not tutorial: - self._tutorial = LinearCreator() + self._tutorial = FiniteStateMachine('Untitled') + self._state = State(name='INIT') + self._tutorial.add_state(self._state) + self._state_counter = 1 else: self._tutorial = tutorial + # TODO load existing tutorial; unused yet self._action_panel = None self._current_filter = None self._intro_mask = None self._intro_handle = None - self._state_bubble = overlayer.TextBubble(self._tutorial.state_name) allocation = self._activity.get_allocation() self._width = allocation.width self._height = allocation.height self._selected_widget = None self._eventmenu = None + self.tuto = None + self._guid = None self._hlmask = overlayer.Rectangle(None, (1.0, 0.0, 0.0, 0.5)) self._activity._overlayer.put(self._hlmask, 0, 0) - self._activity._overlayer.put(self._state_bubble, - self._width/2-self._state_bubble.allocation.width/2, 0) - self._state_bubble.show() - dlg_width = 300 dlg_height = 70 sw = gtk.gdk.screen_width() sh = gtk.gdk.screen_height() - self._tooldialog = gtk.Window() - self._tooldialog.set_title("Tutorius tools") - self._tooldialog.set_transient_for(self._activity) - self._tooldialog.set_decorated(True) - self._tooldialog.set_resizable(False) - self._tooldialog.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY) - self._tooldialog.set_destroy_with_parent(True) - self._tooldialog.set_deletable(False) - self._tooldialog.set_size_request(dlg_width, dlg_height) - - toolbar = gtk.Toolbar() - for tool in addon.list_addons(): - meta = addon.get_addon_meta(tool) - toolitem = ToolButton(meta['icon']) - toolitem.set_tooltip(meta['display_name']) - toolitem.connect("clicked", self._add_action_cb, tool) - toolbar.insert(toolitem, -1) - toolitem = ToolButton("go-next") - toolitem.connect("clicked", self._add_step_cb) - toolitem.set_tooltip("Add Step") - toolbar.insert(toolitem, -1) - toolitem = ToolButton("stop") - toolitem.connect("clicked", self._cleanup_cb) - toolitem.set_tooltip("End Tutorial") - toolbar.insert(toolitem, -1) - self._tooldialog.add(toolbar) - self._tooldialog.show_all() - # simpoir: I suspect the realized widget is a tiny bit larger than - # it should be, thus the -10. - self._tooldialog.move(sw-10-dlg_width, sh-dlg_height) - - self._propedit = EditToolBox(self._activity) - - def _evfilt_cb(self, menuitem, event_name, *args): + + self._propedit = ToolBox(self._activity) + self._propedit.tree.signal_autoconnect({ + 'on_quit_clicked': self._cleanup_cb, + 'on_save_clicked': self.save, + 'on_action_activate': self._add_action_cb, + 'on_event_activate': self._add_event_cb, + }) + self._propedit.window.move( + gtk.gdk.screen_width()-self._propedit.window.get_allocation().width, + 100) + + + self._overview = viewer.Viewer(self._tutorial, self) + self._overview.win.set_transient_for(self._activity) + + self._overview.win.move(0, gtk.gdk.screen_height()- \ + self._overview.win.get_allocation().height) + + self._transitions = dict() + + def _update_next_state(self, state, event, next_state): + self._transitions[event] = next_state + + evts = state.get_event_filter_list() + state.clear_event_filters() + for evt, next_state in evts: + state.add_event_filter(evt, self._transitions[evt]) + + def delete_action(self, action): + """ + Removes the first instance of specified action from the tutorial. + + @param action: the action object to remove from the tutorial + @returns: True if successful, otherwise False. + """ + state = self._tutorial.get_state_by_name("INIT") + + while True: + state_actions = state.get_action_list() + for fsm_action in state_actions: + if fsm_action is action: + state.clear_actions() + if state is self._state: + fsm_action.exit_editmode() + state_actions.remove(fsm_action) + self.set_insertion_point(state.name) + for keep_action in state_actions: + state.add_action(keep_action) + return True + + ev_list = state.get_event_filter_list() + if ev_list: + state = self._tutorial.get_state_by_name(ev_list[0][1]) + continue + + return False + + def delete_state(self): + """ + Remove current state. + Limitation: The last state cannot be removed, as it doesn't have + any transitions to remove anyway. + + @returns: True if successful, otherwise False. + """ + if not self._state.get_event_filter_list(): + # last state cannot be removed + return False + + state = self._tutorial.get_state_by_name("INIT") + ev_list = state.get_event_filter_list() + if state is self._state: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + self.set_insertion_point(next_state.name) + self._tutorial.remove_state(state.name) + self._tutorial.remove_state(next_state.name) + next_state.name = "INIT" + self._tutorial.add_state(next_state) + return True + + # loop to repair links from deleted state + while ev_list: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + if next_state is self._state: + # the tutorial will flush the event filters. We'll need to + # clear and re-add them. + self._tutorial.remove_state(self._state.name) + state.clear_event_filters() + self._update_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1]) + for ev, next_state in ev_list: + state.add_event_filter(ev, next_state) + + self.set_insertion_point(ev_list[0][1]) + return True + + state = next_state + ev_list = state.get_event_filter_list() + return False + + def get_insertion_point(self): + return self._state.name + + def set_insertion_point(self, state_name): + for action in self._state.get_action_list(): + action.exit_editmode() + self._state = self._tutorial.get_state_by_name(state_name) + self._overview.win.queue_draw() + state_actions = self._state.get_action_list() + for action in state_actions: + action.enter_editmode() + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + + if state_actions: + self._propedit.action = state_actions[0] + else: + self._propedit.action = None + + + def _evfilt_cb(self, menuitem, event): """ This will get called once the user has selected a menu item from the event filter popup menu. This should add the correct event filter to the FSM and increment states. """ - self.introspecting = False - eventfilter = addon.create('GtkWidgetEventFilter', - object_id=self._selected_widget, - event_name=event_name) # undo actions so they don't persist through step editing - for action in self._tutorial.current_actions: + for action in self._state.get_action_list(): action.exit_editmode() - self._tutorial.event(eventfilter) - self._state_bubble.label = self._tutorial.state_name self._hlmask.covered = None self._propedit.action = None self._activity.queue_draw() @@ -159,67 +244,70 @@ class Creator(object): self._eventmenu.popup(None, None, None, evt.button, evt.time) self._activity.queue_draw() - def set_intropecting(self, value): - """ - Set whether creator is in UI introspection mode. Setting this will - connect necessary handlers. - @param value True to setup introspection handlers. - """ - if bool(value) ^ bool(self._intro_mask): - if value: - self._intro_mask = overlayer.Mask(catch_events=True) - self._intro_handle = self._intro_mask.connect_after( - "button-press-event", self._intro_cb) - self._activity._overlayer.put(self._intro_mask, 0, 0) - else: - self._intro_mask.catch_events = False - self._intro_mask.disconnect(self._intro_handle) - self._intro_handle = None - self._activity._overlayer.remove(self._intro_mask) - self._intro_mask = None - - def get_introspecting(self): - """ - Whether creator is in UI introspection (catch all event) mode. - @return True if introspection handlers are connected, or False if not. - """ - return bool(self._intro_mask) - - introspecting = property(fset=set_intropecting, fget=get_introspecting) - - def _add_action_cb(self, widget, actiontype): + def _add_action_cb(self, widget, path): """Callback for the action creation toolbar tool""" - action = addon.create(actiontype) - if isinstance(action, actions.Action): - action.enter_editmode() - self._tutorial.action(action) - # FIXME: replace following with event catching - action._drag._eventbox.connect_after( - "button-release-event", self._action_refresh_cb, action) + action_type = self._propedit.actions_list[path][ToolBox.ICON_NAME] + action = addon.create(action_type) + action.enter_editmode() + self._state.add_action(action) + # FIXME: replace following with event catching + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + self._overview.win.queue_draw() + + def _add_event_cb(self, widget, path): + """Callback for the event creation toolbar tool""" + event_type = self._propedit.events_list[path][ToolBox.ICON_NAME] + event = addon.create(event_type) + addonname = type(event).__name__ + meta = addon.get_addon_meta(addonname) + for propname in meta['mandatory_props']: + prop = getattr(type(event), propname) + if isinstance(prop, properties.TUAMProperty): + selector = WidgetSelector(self._activity) + setattr(event, propname, selector.select()) + elif isinstance(prop, properties.TEventType): + try: + dlg = SignalInputDialog(self._activity, + text="Mandatory property", + field=propname, + addr=event.object_id) + setattr(event, propname, dlg.pop()) + except AttributeError: + pass + elif isinstance(prop, properties.TStringProperty): + dlg = TextInputDialog(self._activity, + text="Mandatory property", + field=propname) + setattr(event, propname, dlg.pop()) + else: + raise NotImplementedError() + + event_filters = self._state.get_event_filter_list() + if event_filters: + # linearize tutorial by inserting state + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + self._state.clear_event_filters() + for evt_filt, next_state in event_filters: + new_state.add_event_filter(evt_filt, next_state) + self._update_next_state(self._state, event, new_state.name) + next_state = new_state.name + # blocks are shifted, full redraw is necessary + self._overview.win.queue_draw() else: - 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(text="Mandatory property", - field=propname) - setattr(action, propname, dlg.pop()) - elif isinstance(prop, properties.TIntProperty): - dlg = TextInputDialog(text="Mandatory property", - field=propname) - setattr(action, propname, int(dlg.pop())) - else: - raise NotImplementedError() - - # FIXME: hack to reuse previous introspection code - if not had_introspect: - self._tutorial.event(action) + # append empty state only if edit inserting at end of linearized + # tutorial. + self._update_next_state(self._state, event, str(self._state_counter)) + next_state = str(self._state_counter) + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + + self._state.add_event_filter(event, next_state) + self._tutorial.add_state(new_state) + self._overview.win.queue_draw() + self.set_insertion_point(new_state.name) def _action_refresh_cb(self, widget, evt, action): """ @@ -234,44 +322,54 @@ class Creator(object): "button-release-event", self._action_refresh_cb, action) self._propedit.action = action - def _add_step_cb(self, widget): - """Callback for the "add step" tool""" - self.introspecting = True + self._overview.win.queue_draw() def _cleanup_cb(self, *args): """ Quit editing and cleanup interface artifacts. """ - self.introspecting = False - eventfilter = filters.EventFilter() # undo actions so they don't persist through step editing - for action in self._tutorial.current_actions: + for action in self._state.get_action_list(): action.exit_editmode() - self._tutorial.event(eventfilter) - dlg = TextInputDialog(text=T("Enter a tutorial title."), - field=T("Title")) - tutorialName = "" - while not tutorialName: tutorialName = dlg.pop() - dlg.destroy() - - # prepare tutorial for serialization - tuto = Tutorial(tutorialName, self._tutorial.fsm) - bundle = vault.TutorialBundler() - bundle.write_metadata_file(tuto) - bundle.write_fsm(self._tutorial.fsm) + dialog = gtk.MessageDialog( + parent=self._activity, + flags=gtk.DIALOG_MODAL, + type=gtk.MESSAGE_QUESTION, + buttons=gtk.BUTTONS_YES_NO, + message_format=T('Do you want to save before stopping edition?')) + do_save = dialog.run() + dialog.destroy() + if do_save == gtk.RESPONSE_YES: + self.save() # remove UI remains self._hlmask.covered = None self._activity._overlayer.remove(self._hlmask) - self._activity._overlayer.remove(self._state_bubble) self._hlmask.destroy() self._hlmask = None - self._tooldialog.destroy() self._propedit.destroy() + self._overview.destroy() self._activity.queue_draw() del self._activity._creator + def save(self, widget=None): + if not self.tuto: + dlg = TextInputDialog(self._activity, + text=T("Enter a tutorial title."), + field=T("Title")) + tutorialName = "" + while not tutorialName: tutorialName = dlg.pop() + dlg.destroy() + + # prepare tutorial for serialization + self.tuto = Tutorial(tutorialName, self._tutorial) + bundle = vault.TutorialBundler(self._guid) + self._guid = bundle.Guid + bundle.write_metadata_file(self.tuto) + bundle.write_fsm(self._tutorial) + + def launch(*args, **kwargs): """ Launch and attach a creator to the currently running activity. @@ -281,46 +379,59 @@ class Creator(object): activity._creator = Creator(activity) launch = staticmethod(launch) -class EditToolBox(gtk.Window): - """Helper toolbox class for managing action properties""" - def __init__(self, parent, action=None): - """ - Create the property edition toolbox and display it. +class ToolBox(object): + ICON_LABEL = 0 + ICON_IMAGE = 1 + ICON_NAME = 2 + ICON_TIP = 3 + def __init__(self, parent): + super(ToolBox, self).__init__() + self.__parent = parent + sugar_prefix = os.getenv("SUGAR_PREFIX",default="/usr") + glade_file = os.path.join(sugar_prefix, 'share', 'tutorius', + 'ui', 'creator.glade') + self.tree = gtk.glade.XML(glade_file) + self.window = self.tree.get_widget('mainwindow') + self._propbox = self.tree.get_widget('propbox') + + self.window.set_transient_for(parent) - @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): + self.actions_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.actions_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + self.events_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.events_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + + for toolname in addon.list_addons(): + meta = addon.get_addon_meta(toolname) + iconfile = gtk.Image() + iconfile.set_from_file(icon.get_icon_file_name(meta['icon'])) + img = iconfile.get_pixbuf() + label = format_multiline(meta['display_name']) + + if meta['type'] == addon.TYPE_ACTION: + self.actions_list.append((label, img, toolname, meta['display_name'])) + else: + self.events_list.append((label, img, toolname, meta['display_name'])) + + iconview_action = self.tree.get_widget('iconview1') + iconview_action.set_model(self.actions_list) + iconview_action.set_text_column(self.ICON_LABEL) + iconview_action.set_pixbuf_column(self.ICON_IMAGE) + iconview_action.set_tooltip_column(self.ICON_TIP) + iconview_event = self.tree.get_widget('iconview2') + iconview_event.set_model(self.events_list) + iconview_event.set_text_column(self.ICON_LABEL) + iconview_event.set_pixbuf_column(self.ICON_IMAGE) + iconview_event.set_tooltip_column(self.ICON_TIP) + + self.window.show() + + def destroy(self): + """ clean and free the toolbox """ + self.window.destroy() + + def refresh_properties(self): """Refresh property values from the selected action.""" if self._action is None: return @@ -333,6 +444,9 @@ class EditToolBox(gtk.Window): if isinstance(prop, properties.TStringProperty): propwdg = row.get_children()[1] propwdg.get_buffer().set_text(propval) + elif isinstance(prop, properties.TUAMProperty): + propwdg = row.get_children()[1] + propwdg.set_label(propval) elif isinstance(prop, properties.TIntProperty): propwdg = row.get_children()[1] propwdg.set_value(propval) @@ -348,12 +462,10 @@ class EditToolBox(gtk.Window): def set_action(self, action): """Setter for the action property.""" if self._action is action: - self.refresh() + self.refresh_properties() return - parent = self._propbox.get_parent() - parent.remove(self._propbox) - self._propbox = gtk.VBox(spacing=10) - parent.add(self._propbox) + for old_prop in self._propbox.get_children(): + self._propbox.remove(old_prop) self._action = action if action is None: @@ -368,6 +480,10 @@ class EditToolBox(gtk.Window): propwdg.get_buffer().set_text(propval) propwdg.connect_after("focus-out-event", \ self._str_prop_changed, action, propname) + elif isinstance(prop, properties.TUAMProperty): + propwdg = gtk.Button(propval) + propwdg.connect_after("clicked", \ + self._uam_prop_changed, action, propname) elif isinstance(prop, properties.TIntProperty): adjustment = gtk.Adjustment(value=propval, lower=prop.lower_limit.limit, @@ -388,8 +504,8 @@ class EditToolBox(gtk.Window): propwdg.set_text(str(propval)) row.pack_end(propwdg) self._propbox.pack_start(row, expand=False) - self._vbox.show_all() - self.refresh() + self._propbox.show_all() + self.refresh_properties() def get_action(self): """Getter for the action property""" @@ -406,6 +522,11 @@ class EditToolBox(gtk.Window): except ValueError: widget.set_text(str(getattr(action, propname)[idx])) self.__parent._creator._action_refresh_cb(None, None, action) + def _uam_prop_changed(self, widget, action, propname): + selector = WidgetSelector(self.__parent) + selection = selector.select() + setattr(action, propname, selection) + self.__parent._creator._action_refresh_cb(None, None, action) def _str_prop_changed(self, widget, evt, action, propname): buf = widget.get_buffer() setattr(action, propname, buf.get_text(buf.get_start_iter(), buf.get_end_iter())) @@ -414,9 +535,143 @@ class EditToolBox(gtk.Window): setattr(action, propname, widget.get_value_as_int()) self.__parent._creator._action_refresh_cb(None, None, action) + +class WidgetSelector(object): + """ + Allow selecting a widget from within a window without interrupting the + flow of the current call. + + The selector will run on the specified window until either a widget + is selected or abort() gets called. + """ + def __init__(self, window): + super(WidgetSelector, self).__init__() + self.window = window + self._intro_mask = None + self._intro_handle = None + self._select_handle = None + self._prelight = None + + def select(self): + """ + Starts selecting a widget, by grabbing control of the mouse and + highlighting hovered widgets until one is clicked. + @returns: a widget address or None + """ + if not self._intro_mask: + self._prelight = None + self._intro_mask = overlayer.Mask(catch_events=True) + self._select_handle = self._intro_mask.connect_after( + "button-press-event", self._end_introspect) + self._intro_handle = self._intro_mask.connect_after( + "motion-notify-event", self._intro_cb) + self.window._overlayer.put(self._intro_mask, 0, 0) + self.window._overlayer.queue_draw() + + while bool(self._intro_mask) and not gtk.main_iteration(): + pass + + return gtkutils.raddr_lookup(self._prelight) + + def _end_introspect(self, widget, evt): + if evt.type == gtk.gdk.BUTTON_PRESS and self._prelight: + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + # for some reason, gtk may not redraw after this unless told to. + self.window.queue_draw() + + def _intro_cb(self, widget, evt): + """ + Callback for capture of widget events, when in introspect mode. + """ + # widget has focus, let's hilight it + win = gtk.gdk.display_get_default().get_window_at_pointer() + if not win: + return + click_wdg = win[0].get_user_data() + if not click_wdg.is_ancestor(self.window._overlayer): + # as popups are not (yet) supported, it would break + # badly if we were to play with a widget not in the + # hierarchy. + return + for hole in self._intro_mask.pass_thru: + self._intro_mask.mask(hole) + self._intro_mask.unmask(click_wdg) + self._prelight = click_wdg + + self.window.queue_draw() + + def abort(self): + """ + Ends the selection. The control will return to the select() caller + with a return value of None, as selection was aborted. + """ + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + self._prelight = None + +class SignalInputDialog(gtk.MessageDialog): + def __init__(self, parent, text, field, addr): + """ + Create a gtk signal selection dialog. + + @param parent: the parent window this dialog should stay over. + @param text: the title of the dialog. + @param field: the field description of the dialog. + @param addr: the widget address from which to fetch signal list. + """ + gtk.MessageDialog.__init__(self, parent, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + gtk.MESSAGE_QUESTION, + gtk.BUTTONS_OK, + None) + self.set_markup(text) + self.model = gtk.ListStore(str) + widget = gtkutils.find_widget(parent, addr) + for signal_name in gobject.signal_list_names(widget): + self.model.append(row=(signal_name,)) + self.entry = gtk.ComboBox(self.model) + cell = gtk.CellRendererText() + self.entry.pack_start(cell) + self.entry.add_attribute(cell, 'text', 0) + hbox = gtk.HBox() + lbl = gtk.Label(field) + hbox.pack_start(lbl, False) + hbox.pack_end(self.entry) + self.vbox.pack_end(hbox, True, True) + self.show_all() + + def pop(self): + """ + Show the dialog. It will run in it's own loop and return control + to the caller when a signal has been selected. + + @returns: a signal name or None if no signal was selected + """ + self.run() + self.hide() + iter = self.entry.get_active_iter() + if iter: + text = self.model.get_value(iter, 0) + return text + return None + + def _dialog_done_cb(self, entry, response): + self.response(response) + class TextInputDialog(gtk.MessageDialog): - def __init__(self, text, field): - gtk.MessageDialog.__init__(self, None, + def __init__(self, parent, text, field): + gtk.MessageDialog.__init__(self, parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_QUESTION, gtk.BUTTONS_OK, @@ -440,4 +695,39 @@ class TextInputDialog(gtk.MessageDialog): def _dialog_done_cb(self, entry, response): self.response(response) +# The purpose of this function is to reformat text, as current IconView +# implentation does not insert carriage returns on long lines. +# To preserve layout, this call reformat text to fit in small space under an +# icon. +def format_multiline(text, length=10, lines=3, line_separator='\n'): + """ + Reformat a text to fit in a small space. + + @param length: maximum char per line + @param lines: maximum number of lines + """ + words = text.split(' ') + line = list() + return_val = [] + linelen = 0 + + for word in words: + t_len = linelen+len(word) + if t_len < length: + line.append(word) + linelen = t_len+1 # count space + else: + if len(return_val)+1 < lines: + return_val.append(' '.join(line)) + line = list() + linelen = 0 + line.append(word) + else: + return_val.append(' '.join(line+['...'])) + return line_separator.join(return_val) + + return_val.append(' '.join(line)) + return line_separator.join(return_val) + + # vim:set ts=4 sts=4 sw=4 et: diff --git a/tutorius/editor.py b/tutorius/editor.py index 42cc718..9d2effe 100644 --- a/tutorius/editor.py +++ b/tutorius/editor.py @@ -24,7 +24,7 @@ import gobject from gettext import gettext as _ -from sugar.tutorius.gtkutils import register_signals_numbered, get_children +from .gtkutils import register_signals_numbered, get_children class WidgetIdentifier(gtk.Window): """ diff --git a/tutorius/engine.py b/tutorius/engine.py index 9c1dae4..e77a018 100644 --- a/tutorius/engine.py +++ b/tutorius/engine.py @@ -1,10 +1,10 @@ import logging import dbus.mainloop.glib from jarabe.model import shell - -from sugar.tutorius.vault import Vault from sugar.bundle.activitybundle import ActivityBundle +from .vault import Vault + class Engine: """ Driver for the execution of tutorials diff --git a/tutorius/filters.py b/tutorius/filters.py index 44621d5..38cf86b 100644 --- a/tutorius/filters.py +++ b/tutorius/filters.py @@ -18,7 +18,7 @@ import logging logger = logging.getLogger("filters") -from sugar.tutorius import properties +from . import properties class EventFilter(properties.TPropContainer): diff --git a/tutorius/linear_creator.py b/tutorius/linear_creator.py index 78e94ce..f664c49 100644 --- a/tutorius/linear_creator.py +++ b/tutorius/linear_creator.py @@ -15,12 +15,12 @@ # 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 +from .core import * +from .actions import * +from .filters import * + class LinearCreator(object): """ This class is used to create a FSM from a linear sequence of orders. The diff --git a/tutorius/overlayer.py b/tutorius/overlayer.py index 0a3d542..b967739 100644 --- a/tutorius/overlayer.py +++ b/tutorius/overlayer.py @@ -58,7 +58,7 @@ class Overlayer(gtk.Layout): @param overlayed widget to be overlayed. Will be resized to full size. """ def __init__(self, overlayed=None): - gtk.Layout.__init__(self) + super(Overlayer, self).__init__() self._overlayed = overlayed if overlayed: @@ -83,7 +83,7 @@ class Overlayer(gtk.Layout): if hasattr(child, "draw_with_context"): # if the widget has the CanvasDrawable protocol, use it. child.no_expose = True - gtk.Layout.put(self, child, x, y) + super(Overlayer, self).put(child, x, y) # be sure to redraw or the overlay may not show self.queue_draw() diff --git a/tutorius/properties.py b/tutorius/properties.py index cbb2ae3..5422532 100644 --- a/tutorius/properties.py +++ b/tutorius/properties.py @@ -19,12 +19,12 @@ TutoriusProperties have the same behaviour as python properties (assuming you also use the TPropContainer), with the added benefit of having builtin dialog prompts and constraint validation. """ +from copy import copy, deepcopy -from sugar.tutorius.constraints import Constraint, \ +from .constraints import Constraint, \ UpperLimitConstraint, LowerLimitConstraint, \ MaxSizeConstraint, MinSizeConstraint, \ ColorConstraint, FileConstraint, BooleanConstraint, EnumConstraint -from copy import copy class TPropContainer(object): """ @@ -95,6 +95,12 @@ class TPropContainer(object): """ return object.__getattribute__(self, "_props").keys() + def get_properties_dict_copy(self): + """ + Return a deep copy of the dictionary of properties from that object. + """ + return deepcopy(self._props) + # Providing the hash methods necessary to use TPropContainers # in a dictionary, according to their properties def __hash__(self): @@ -310,6 +316,8 @@ class TUAMProperty(TutoriusProperty): self.type = "uam" + self.default = self.validate(value) + class TAddonProperty(TutoriusProperty): """ Reprensents an embedded tutorius Addon Component (action, trigger, etc.) @@ -331,6 +339,16 @@ class TAddonProperty(TutoriusProperty): return super(TAddonProperty, self).validate(value) raise ValueError("Expected TPropContainer instance as TaddonProperty value") +class TEventType(TutoriusProperty): + """ + Represents an GUI signal for a widget. + """ + def __init__(self, value): + super(TEventType, self).__init__() + self.type = "gtk-signal" + + self.default = self.validate(value) + class TAddonListProperty(TutoriusProperty): """ Reprensents an embedded tutorius Addon List Component. diff --git a/tutorius/service.py b/tutorius/service.py index 21f0cf1..eb246a1 100644 --- a/tutorius/service.py +++ b/tutorius/service.py @@ -1,7 +1,7 @@ -from engine import Engine import dbus -from dbustools import remote_call +from .engine import Engine +from .dbustools import remote_call _DBUS_SERVICE = "org.tutorius.Service" _DBUS_PATH = "/org/tutorius/Service" diff --git a/tutorius/store.py b/tutorius/store.py index 760daec..2e55d71 100644 --- a/tutorius/store.py +++ b/tutorius/store.py @@ -323,6 +323,10 @@ class StoreProxy(object): headers = { 'X-API-Auth' : self.api_auth_key } response = self.conn.request_post(request_url, None, None, None, headers) + if self.helper.iserror(response): + return -1 + + return tutorial_store_id if self.helper.iserror(response): return False @@ -338,12 +342,22 @@ class StoreProxy(object): headers = { 'X-API-Auth' : self.api_auth_key } response = self.conn.request_post(request_url, tutorial_info, tutorial, tutorial_info['filename'], headers) + + if self.helper.iserror(response): + return -1 + + xml_response = minidom.parseString(response['body']) + + id_node = xml_response.getElementsByTagName("id")[0] + + id = id_node.getAttribute('value') + + return id if self.helper.iserror(response): return False return True - def unpublish(self, tutorial_store_id): """ @@ -470,4 +484,4 @@ class StoreProxyHelper(object): else: tutorial[param] = '' - return tutorial
\ No newline at end of file + return tutorial diff --git a/tutorius/tutorial.py b/tutorius/tutorial.py new file mode 100644 index 0000000..9831a7b --- /dev/null +++ b/tutorius/tutorial.py @@ -0,0 +1,806 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Erick Lavoie <erick.lavoie@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 + +#TODO: For notification of modifications on the Tutorial check for GObject and PyDispatcher for inspiration + +from .constraints import ConstraintException +from .properties import TPropContainer + +_NAME_SEPARATOR = "/" + +class Tutorial(object): + """ This class replaces the previous Tutorial class and + allows manipulation of the abstract representation + of a tutorial as a state machine + """ + + INIT = "INIT" + END = "END" + INITIAL_TRANSITION_NAME = INIT + "/transition0" + + + def __init__(self, name, state_dict=None): + """ + The constructor for the Tutorial. By default, the tutorial contains + only an initial state and an end state. + The initial state doesn't contain any action but it contains + a single automatic transition <Tutorial.INITIAL_TRANSITION_NAME> + between the initial state <Tutorial.INIT> and the end state + <Tutorial.END>. + + The end state doesn't contain any action nor transition. + + If state_dict is provided, a valid initial state and an end state + must be provided. + + @param name The name of the tutorial + @param state_dict optional, a valid dictionary of states + @raise InvalidStateDictionary + """ + self.name = name + + + # We will use an adjacency list representation through the + # usage of state objects because our graph representation + # is really sparse and mostly linear, for a brief + # example of graph programming in python see: + # http://www.python.org/doc/essays/graphs + if not state_dict: + self._state_dict = \ + {Tutorial.INIT:State(name=Tutorial.INIT),\ + Tutorial.END:State(name=Tutorial.END)} + + self.add_transition(Tutorial.INIT, \ + (AutomaticTransitionEvent(), Tutorial.END)) + else: + raise NotImplementedError("Tutorial: Initilization from a dictionary is not supported yet") + + + # Minimally check for the presence of an INIT and an END + # state + if not self._state_dict.has_key(Tutorial.INIT): + raise Exception("No INIT state found in state_dict") + + if not self._state_dict.has_key(Tutorial.END): + raise Exception("No END state found in state_dict") + + # TODO: Validate once validation is working + #self.validate() + + # Initialize variables for generating unique names + # TODO: We should take the max number from the + # existing state names + self._state_name_nb = 0 + + + def add_state(self, action_list=(), transition_list=()): + """ + Add a new state to the state machine. The state is + initialized with the action list and transition list + and a new unique name is returned for this state. + + The actions are added using add_action. + + The transitions are added using add_transition. + + @param action_list The list of valid actions for this state + @param transition_list The list of valid transitions + @return unique name for this state + """ + name = self._generate_unique_state_name() + + for action in action_list: + self._validate_action(action) + + for transition in transition_list: + self._validate_transition(transition) + + state = State(name, action_list, transition_list) + + self._state_dict[name] = state + + return name + + + def add_action(self, state_name, action): + """ + Add an action to a specific state. A name unique throughout the + tutorial is generated to refer precisely to this action + and is returned. + + The action is validated. + + @param state_name The name of the state to add an action to + @param action The action to be added + @return unique name for this action + @raise LookupError if state_name doesn't exist + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + self._validate_action(action) + + return self._state_dict[state_name].add_action(action) + + def add_transition(self, state_name, transition): + """ + Add a transition to a specific state. A name unique throughout the + tutorial is generated to refer precisely to this transition + and is returned. Inserting a duplicate transition will raise + an exception. + + The transition is validated. + + @param state_name The name of the state to add a transition to + @param transition The transition to be added + @return unique name for this action + @raise LookupError if state_name doesn't exist + @raise TransitionAlreadyExists + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + self._validate_transition(transition) + + # The unicity of the transition is validated by the state + return self._state_dict[state_name].add_transition(transition) + + def update_action(self, action_name, new_properties): + """ + Update the action with action_name with a property dictionary + new_properties. If one property update is invalid, the old + values are restored and an exception is raised. + + @param action_name The name of the action to update + @param new_properties The properties that will update the action + @return old properties from the action + @raise LookupError if action_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + state_name = self._validate_state_name(action_name) + + #TODO: We should validate that only properties defined on the action + # are passed in + + return self._state_dict[state_name].update_action(action_name, new_properties) + + def update_transition(self, transition_name, new_properties=None, new_state=None): + """ + Update the transition with transition_name with new properties and/or + a new state to transition to. A None value means that the corresponding + value won't be updated. If one property update is invalid, the old + values are restored and an exception is raised. + + @param transition_name The name of the transition to replace + @param new_properties The properties that will update the transition + @param new_state The new state to transition to + @return a tuple (old_properties, old_state) with previous values + @raise LookupError if transition_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + state_name = self._validate_state_name(transition_name) + + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: transition <" + transition_name +\ + "> is not defined") + + if new_state and not self._state_dict.has_key(new_state): + raise LookupError("Tutorial: destination state <" + new_state +\ + "> is not defined") + + #TODO: We should validate that only properties defined on the action + # are passed in + + return self._state_dict[state_name].update_transition(transition_name, new_properties, new_state) + + def delete_action(self, action_name): + """ + Delete the action identified by action_name. + + @param action_name The name of the action to be deleted + @return the action that has been deleted + @raise LookupError if transition_name doesn't exist + """ + state_name = self._validate_state_name(action_name) + + return self._state_dict[state_name].delete_action(action_name) + + def delete_transition(self, transition_name): + """ + Delete the transition identified by transition_name. + + @param transition_name The name of the transition to be deleted + @return the transition that has been deleted + @raise LookupError if transition_name doesn't exist + """ + state_name = self._validate_state_name(transition_name) + + return self._state_dict[state_name].delete_transition(transition_name) + + def delete_state(self, state_name): + """ + Delete the state, delete all the actions and transitions + in this state, update the transitions from the state that + pointed to this one to point to the next state and remove all the + unreachable states recursively. + + All but the INIT and END states can be deleted. + + @param state_name The name of the state to remove + @return The deleted state + @raise StateDeletionError when trying to delete the INIT or the END state + @raise LookupError if state_name doesn't exist + """ + self._validate_state_name(state_name) + + if state_name == Tutorial.INIT or state_name == Tutorial.END: + raise StateDeletionError("<" + state_name + "> cannot be deleted") + + next_states = set(self.get_following_states_dict(state_name).values()) + previous_states = set(self.get_previous_states_dict(state_name).values()) + + # For now tutorials should be completely linear, + # let's make sure they are + assert len(next_states) <= 1 and len(previous_states) <= 1 + + # Update transitions only if they existed + if len(next_states) == 1 and len(previous_states) == 1: + next_state = next_states.pop() + previous_state = previous_states.pop() + + transitions = previous_state.get_transition_dict() + for transition_name, (event, state_to_delete) in \ + transitions.iteritems(): + self.update_transition(transition_name, None, next_state.name) + + # Since we assume tutorials are linear for now, we do not need + # to search for unreachable states + + return self._state_dict.pop(state_name) + + + + def get_action_dict(self, state_name=None): + """ + Returns a reference to the dictionary of all actions for a specific + state. + If no state_name is provided, returns an action dictionary + containing actions for all states. + + @param state_name The name of the state to list actions from + @return A dictionary of actions with action_name as key and action + as value for state_name + @raise LookupError if state_name doesn't exist + """ + if state_name and not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + elif state_name: + return self._state_dict[state_name].get_action_dict() + else: + action_dict = {} + for state in self._state_dict.itervalues(): + action_dict.update(state.get_action_dict()) + return action_dict + + def get_transition_dict(self, state_name=None): + """ + Returns a dictionary of all actions for a specific state. + If no state_name is provided, returns an action dictionary + containing actions for all states. + + @param state_name The name of the state to list actions from + @return A dictionary of transitions with transition_name as key and transition as value for state_name + @raise LookupError if state_name doesn't exist + """ + if state_name and not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + elif state_name: + return self._state_dict[state_name].get_transition_dict() + else: + transition_dict = {} + for state in self._state_dict.itervalues(): + transition_dict.update(state.get_transition_dict()) + return transition_dict + + + def get_state_dict(self): + """ + Returns a reference to the internal state dictionary used by + the Tutorial. + + @return A reference to the dictionary of all the states in the tutorial with state_name as key and state as value + """ + # Maybe we will need to change it for an immutable dictionary + # to make sure the internal representation is not modified + return self._state_dict + + def get_following_states_dict(self, state_name): + """ + Returns a dictionary of the states that are immediately reachable from + a specific state. + + @param state_name The name of the state + @raise LookupError if state_name doesn't exist + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + following_states_dict = {} + for (event, next_state) in \ + self._state_dict[state_name].get_transition_dict().itervalues(): + following_states_dict[next_state] = self._state_dict[next_state] + + return following_states_dict + + def get_previous_states_dict(self, state_name): + """ + Returns a dictionary of the states that can transition to a + specific state. + + @param state_name The name of the state + @raise LookupError if state_name doesn't exist + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + + previous_states_dict = {} + for iter_state_name, state in \ + self._state_dict.iteritems(): + + for (event, next_state) in \ + self._state_dict[iter_state_name].get_transition_dict().itervalues(): + + if next_state != state_name: + continue + + previous_states_dict[iter_state_name] = state + # if we have found one, do not look for other transitions + # from this state + break + + return previous_states_dict + + # Convenience methods for common tutorial manipulations + def add_state_before(self, state_name, action_list=[], event_list=[]): + """ + Add a new state just before another state state_name. All transitions + going to state_name are updated to end on the new state and all + events will be converted to transitions ending on state_name. + + When event_list is empty, an automatic transition to state_name + will be added to maintain consistency. + + @param state_name The name of the state that will be preceded by the + new state + @param action_list The list of valid actions for this state + @param event_list The list of events that will be converted to transitions to state_name + @return unique name for this state + @raise LookupError if state_name doesn't exist + """ + raise NotImplementedError + + # Callback mecanism to allow automatic change notification when + # the tutorial is modified + def register_action_added_cb(self, cb): + """ + Register a function cb that will be called when any action from + the tutorial is added. + + cb should be of the form: + + cb(action_name, new_action) where: + action_name is the unique name of the action that was added + new_action is the new action + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + def register_action_updated_cb(self, cb): + """ + Register a function cb that will be called when any action from + the tutorial is updated. + + cb should be of the form: + + cb(action_name, new_action) where: + action_name is the unique name of the action that has changed + new_action is the new action that replaces the old one + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + def register_action_deleted_cb(self, cb): + """ + Register a function cb that will be called when any action from + the tutorial is deleted. + + cb should be of the form: + + cb(action_name, old_action) where: + action_name is the unique name of the action that was deleted + old_action is the new action that replaces the old one + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + def register_transition_updated_cb(self, cb): + """ + Register a function cb that will be called when any transition from + the tutorial is updated. + + cb should be of the form: + + cb(transition_name, new_transition) where: + transition_name is the unique name of the transition + that has changed + new_transition is the new transition that replaces the old one + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + # Validation to assert precondition + def _validate_action(self, action): + """ + Validate that an action conforms to what we expect, + throws an exception otherwise. + + @param action The action to validate + @except InvalidAction if the action fails to conform to what we expect + """ + pass + + def _validate_transition(self, transition): + """ + Validate that a transition conforms to what we expect, + throws an exception otherwise. + + @param transition The transition to validate + @except InvalidTransition if the transition fails to conform to what we expect + """ + pass + + # Validation decorators to assert preconditions + def _validate_state_name(self,name): + """ + Assert that the state name found in the first part of the string + actually exists + + @param name The name that starts with a state name + @return the state_name from name + @raise LookupError if state_name doesn't exist + """ + state_name = name + + if name.find(_NAME_SEPARATOR) != -1: + state_name = name[:name.find(_NAME_SEPARATOR)] + + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + str(state_name) +\ + "> is not defined") + + return state_name + + def validate(self): + """ + Validate the state machine for a serie of properties: + 1. No unreachable states + 2. No dead end state (except END) + 3. No branching in the main path + 4. No loop in the main path + 5. ... + + Throw an exception for the first condition that is not met. + """ + raise NotImplementedError + + def _generate_unique_state_name(self): + name = "State" + str(self._state_name_nb) + self._state_name_nb += 1 + return name + + def __str__(self): + """ + Return a string representation of the tutorial + """ + return str(self._state_dict) + + +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 transitions to lead + to next states. + + This class is not meant to be used explicitly as no validation is done on + inputs, the validation should be done by the containing class. + """ + + def __init__(self, name, action_list=(), transition_list=()): + """ + Initializes the content of the state, such as loading the actions + that are required and building the correct transitions. + + @param action_list The list of actions to execute when entering this + state + @param transition_list A list of tuples of the form + (event, next_state_name), that explains the outgoing links for + this state + """ + object.__init__(self) + + self.name = name + + # Initialize internal variables for name generation + self.action_name_nb = 0 + self.transition_name_nb = 0 + + self._actions = {} + for action in action_list: + self.add_action(action) + + self._transitions = {} + for transition in transition_list: + self.add_transition(transition) + + + # Action manipulations + def add_action(self, new_action): + """ + Adds an action to the state + + @param new_action The action to add + @return a unique name for this action + """ + action_name = self._generate_unique_action_name(new_action) + self._actions[action_name] = new_action + return action_name + + def delete_action(self, action_name): + """ + Delete the action with the name action_name + + @param action_name The name of the action to delete + @return The action deleted + @raise LookupError if action_name doesn't exist + """ + if self._actions.has_key(action_name): + return self._actions.pop(action_name) + else: + raise LookupError("Tutorial.State: action <" + action_name + "> is not defined") + + def update_action(self, action_name, new_properties): + """ + Update the action with action_name with a property dictionary + new_properties. If one property update is invalid, the old + values are restored and an exception is raised. + + @param action_name The name of the action to update + @param new_properties The properties that will update the action + @return The old properties from the action + @raise LookupError if action_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + if not self._actions.has_key(action_name): + raise LookupError("Tutorial.State: action <" + action_name + "> is not defined") + + action = self._actions[action_name] + old_properties = action.get_properties_dict_copy() + try: + for property_name, property_value in new_properties.iteritems(): + action.__setattr__(property_name, property_value) + return old_properties + except ConstraintException, e: + action._props = old_properties + raise e + + def get_action_dict(self): + """ + Return the reference to the internal action dictionary. + + @return A dictionary of actions that the state will execute + """ + return self._actions + + def delete_actions(self): + """ + Removes all the action associated with this state. A cleared state will + not do anything when entered or exited. + """ + self._actions = {} + + # Transition manipulations + def add_transition(self, new_transition): + """ + Adds a transition from this state to another state. + + The same transition may not be added twice. + + @param transition The new transition. + @return A unique name for the transition + @raise TransitionAlreadyExists if an equivalent transition exists + """ + for transition in self._transitions.itervalues(): + if transition == new_transition: + raise TransitionAlreadyExists(str(transition)) + + transition_name = self._generate_unique_transition_name(new_transition) + self._transitions[transition_name] = new_transition + return transition_name + + def update_transition(self, transition_name, new_properties=None, new_state=None): + """ + Update the transition with transition_name with new properties and/or + a new state to transition to. A None value means that the corresponding + value won't be updated. If one property update is invalid, the old + values are restored and an exception is raised. + + @param transition_name The name of the transition to replace + @param new_properties The properties that will update the event on the transition + @param new_state The new state to transition to + @return a tuple (old_properties, old_state) with previous values + @raise LookupError if transition_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + if not self._transitions.has_key(transition_name): + raise LookupError("Tutorial.State: transition <" + transition_name + "> is not defined") + + transition = self._transitions[transition_name] + + tmp_event = transition[0] + tmp_state = transition[1] + + prop = new_properties or {} + + old_properties = transition[0].get_properties_dict_copy() + old_state = transition[1] + + try: + for property_name, property_value in prop.iteritems(): + tmp_event.__setattr__(property_name, property_value) + except ConstraintException, e: + tmp_event._props = old_properties + raise e + + if new_state: + tmp_state = new_state + + self._transitions[transition_name] = (tmp_event, tmp_state) + + return (old_properties, old_state) + + def delete_transition(self, transition_name): + """ + Delete the transition with the name transition_name + + @param transition_name The name of the transition to delete + @return The transition deleted + @raise LookupError if transition_name doesn't exist + """ + if self._transitions.has_key(transition_name): + return self._transitions.pop(transition_name) + else: + raise LookupError("Tutorial.State: transition <" + transition_name + "> is not defined") + + def get_transition_dict(self): + """ + Return the reference to the internal transition dictionary. + + @return The dictionary of transitions associated with this state. + """ + return self._transitions + + def delete_transitions(self): + """ + Delete all the transitions associated with this state. + """ + self._transitions = {} + + def _generate_unique_action_name(self, action): + """ + Returns a unique name for the action in this state, + the actual content of the name should not be relied upon + for correct behavior + + @param action The action to generate a name for + @return A name garanteed to be unique within this state + """ + #TODO use the action class name to generate a name + # to make it easier to debug and know what we are + # manipulating + name = self.name + _NAME_SEPARATOR + "action" + str(self.action_name_nb) + self.action_name_nb += 1 + return name + + def _generate_unique_transition_name(self, transition): + """ + Returns a unique name for the transition in this state, + the actual content of the name should not be relied upon + for correct behavior + + @param transition The transition to generate a name for + @return A name garanteed to be unique within this state + """ + #TODO use the event class name from the transition to + # generate a name to make it easier to debug and know + # what we are manipulating + name = self.name + _NAME_SEPARATOR + "transition" + str(self.transition_name_nb) + self.transition_name_nb += 1 + return name + + def __eq__(self, otherState): + """ + Compare current state to otherState. + + Two states are considered equal if and only if: + -every action in this state has a matching action in the + other state with the same properties and values + -every event filters in this state has a matching filter in the + other state having the same properties and values + -both states have the same name. + + + @param otherState The state that will be compared to this one + @return True if the states are the same, False otherwise +` """ + raise NotImplementedError + +#TODO: Define the automatic transition in the same way as +# other events +class AutomaticTransitionEvent(TPropContainer): + pass + + +################## Error Handling and Exceptions ############################## + +class TransitionAlreadyExists(Exception): + """ + Raised when a duplicate transition is added to a state + """ + pass + + +class InvalidStateDictionary(Exception): + """ + Raised when an initialization dictionary could not be used to initialize + a tutorial + """ + pass + +class StateDeletionError(Exception): + """ + Raised when trying to delete an INIT or an END state from a tutorial + """ + pass diff --git a/tutorius/vault.py b/tutorius/vault.py index cc2a3f6..b455a52 100644 --- a/tutorius/vault.py +++ b/tutorius/vault.py @@ -28,11 +28,11 @@ import uuid import xml.dom.minidom from xml.dom import NotFoundErr import zipfile - -from sugar.tutorius import addon -from sugar.tutorius.core import Tutorial, State, FiniteStateMachine from ConfigParser import SafeConfigParser +from . import addon +from .core import Tutorial, State, FiniteStateMachine + logger = logging.getLogger("tutorius") # this is where user installed/generated tutorials will go @@ -73,7 +73,7 @@ class Vault(object): given activity. @param activity_name the name of the activity associated with this tutorial. None means ALL activities - @param activity_vers the version number of the activity to find tutorail for. 0 means find for ANY version. Ifactivity_ame is None, version number is not used + @param activity_vers the version number of the activity to find tutorail for. 0 means find for ANY version. If activity_name is None, version number is not used @returns a map of tutorial {names : GUID}. """ # check both under the activity data and user installed folders diff --git a/tutorius/viewer.py b/tutorius/viewer.py new file mode 100644 index 0000000..272558e --- /dev/null +++ b/tutorius/viewer.py @@ -0,0 +1,423 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module renders a widget containing a graphical representation +of a tutorial and acts as a creator proxy as it has some editing +functionality. +""" +import sys + +import gtk, gtk.gdk +import cairo +from math import pi as PI +PI2 = PI/2 + +import rsvg + +from sugar.bundle import activitybundle +from sugar.tutorius import addon +from sugar.graphics import icon +from sugar.tutorius.filters import EventFilter +from sugar.tutorius.actions import Action +import os + +# FIXME ideally, apps scale correctly and we should use proportional positions +X_WIDTH = 800 +X_HEIGHT = 600 +ACTION_WIDTH = 100 +ACTION_HEIGHT = 70 + +# block look +BLOCK_PADDING = 5 +BLOCK_WIDTH = 100 +BLOCK_CORNERS = 10 +BLOCK_INNER_PAD = 10 + +SNAP_WIDTH = BLOCK_WIDTH - BLOCK_PADDING - BLOCK_INNER_PAD*2 +SNAP_HEIGHT = SNAP_WIDTH*X_HEIGHT/X_WIDTH +SNAP_SCALE = float(SNAP_WIDTH)/X_WIDTH + +class Viewer(object): + """ + Renders a tutorial as a sequence of blocks, each block representing either + an action or an event (transition). + + Current Viewer implementation lacks viewport management; + having many objects in a tutorial will not render properly. + """ + def __init__(self, tutorial, creator): + super(Viewer, self).__init__() + + self._tutorial = tutorial + self._creator = creator + self.alloc = None + self.click_pos = None + self.drag_pos = None + self.selection = [] + + self.win = gtk.Window(gtk.WINDOW_TOPLEVEL) + self.win.set_size_request(400, 200) + self.win.set_gravity(gtk.gdk.GRAVITY_SOUTH_WEST) + self.win.show() + self.win.set_deletable(False) + self.win.move(0, 0) + + vbox = gtk.ScrolledWindow() + self.win.add(vbox) + + canvas = gtk.DrawingArea() + vbox.add_with_viewport(canvas) + canvas.set_app_paintable(True) + canvas.connect_after("expose-event", self.on_viewer_expose, tutorial._states) + canvas.add_events(gtk.gdk.BUTTON_PRESS_MASK \ + |gtk.gdk.BUTTON_MOTION_MASK \ + |gtk.gdk.BUTTON_RELEASE_MASK \ + |gtk.gdk.KEY_PRESS_MASK) + canvas.connect('button-press-event', self._on_click) + # drag-select disabled, for now + #canvas.connect('motion-notify-event', self._on_drag) + canvas.connect('button-release-event', self._on_drag_end) + canvas.connect('key-press-event', self._on_key_press) + + canvas.set_flags(gtk.HAS_FOCUS|gtk.CAN_FOCUS) + canvas.grab_focus() + + self.win.show_all() + canvas.set_size_request(2048, 180) # FIXME + + def destroy(self): + self.win.destroy() + + + def _paint_state(self, ctx, states): + """ + Paints a tutorius fsm state in a cairo context. + Final context state will be shifted by the size of the graphics. + """ + block_width = BLOCK_WIDTH - BLOCK_PADDING + block_max_height = self.alloc.height + + new_insert_point = None + cur_state = 'INIT' + + # FIXME: get app when we have a model that supports it + cur_app = 'Calculate' + app_start = ctx.get_matrix() + try: + state = states[cur_state] + except KeyError: + state = None + + while state: + new_app = 'Calculate' + if new_app != cur_app: + ctx.save() + ctx.set_matrix(app_start) + self._render_app_hints(ctx, cur_app) + ctx.restore() + app_start = ctx.get_matrix() + ctx.translate(BLOCK_PADDING, 0) + cur_app = new_app + + action_list = state.get_action_list() + if action_list: + local_height = (block_max_height - BLOCK_PADDING)/len(action_list) - BLOCK_PADDING + ctx.save() + for action in action_list: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos and \ + self.click_pos[0]-BLOCK_WIDTH<origin[0] and \ + self.drag_pos[0]>origin[0]: + self.selection.append(action) + self.render_action(ctx, block_width, local_height, action) + ctx.translate(0, local_height+BLOCK_PADDING) + + ctx.restore() + ctx.translate(BLOCK_WIDTH, 0) + + # insertion cursor painting made from two opposed triangles + # joined by a line. + if state.name == self._creator.get_insertion_point(): + ctx.save() + bp2 = BLOCK_PADDING/2 + ctx.move_to(-bp2, 0) + ctx.line_to(-BLOCK_PADDING-bp2, -BLOCK_PADDING) + ctx.line_to(bp2, -BLOCK_PADDING) + ctx.line_to(-bp2, 0) + + ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING) + ctx.line_to(bp2, block_max_height-BLOCK_PADDING) + ctx.line_to(-BLOCK_PADDING-bp2, block_max_height-BLOCK_PADDING) + ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING) + + ctx.line_to(-bp2, BLOCK_PADDING) + ctx.set_source_rgb(1.0, 1.0, 0.0) + ctx.stroke_preserve() + ctx.fill() + ctx.restore() + + + event_list = state.get_event_filter_list() + if event_list: + local_height = (block_max_height - BLOCK_PADDING)/len(event_list) - BLOCK_PADDING + ctx.save() + for event, next_state in event_list: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos and \ + self.click_pos[0]-BLOCK_WIDTH<origin[0] and \ + self.drag_pos[0]>origin[0]: + self.selection.append(event) + self.render_event(ctx, block_width, local_height, event) + ctx.translate(0, local_height+BLOCK_PADDING) + + ctx.restore() + ctx.translate(BLOCK_WIDTH, 0) + + # FIXME point to next state in state, as it would highlight + # the "happy path". + cur_state = event_list[0][1] + + if (not new_insert_point) and self.click_pos: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos[0]<origin[0]: + new_insert_point = state + + if event_list: + try: + state = states[cur_state] + except KeyError: + break + yield True + else: + break + + ctx.set_matrix(app_start) + self._render_app_hints(ctx, cur_app) + + if self.click_pos: + if not new_insert_point: + new_insert_point = state + + self._creator.set_insertion_point(new_insert_point.name) + + yield False + + def _render_snapshot(self, ctx, elem): + """ + Render the "simplified screenshot-like" representation of elements positions. + """ + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.5) + ctx.rectangle(0, 0, SNAP_WIDTH, SNAP_HEIGHT) + ctx.fill_preserve() + ctx.stroke() + + if hasattr(elem, 'position'): + pos = elem.position + # FIXME this size approximation is fine, but I believe we could + # do better. + ctx.scale(SNAP_SCALE, SNAP_SCALE) + ctx.rectangle(pos[0], pos[1], ACTION_WIDTH, ACTION_HEIGHT) + ctx.fill_preserve() + ctx.stroke() + + def _render_app_hints(self, ctx, appname): + """ + Fetches the icon of the app related to current states and renders it on a + separator, between states. + """ + ctx.set_source_rgb(0.0, 0.0, 0.0) + ctx.set_dash((1,1,0,0), 1) + ctx.move_to(0, 0) + ctx.line_to(0, self.alloc.height) + ctx.stroke() + ctx.set_dash(tuple(), 1) + + bundle_path = os.getenv("SUGAR_BUNDLE_PATH") + if bundle_path: + icon_path = activitybundle.ActivityBundle(bundle_path).get_icon() + icon = rsvg.Handle(icon_path) + ctx.save() + ctx.translate(-15, 0) + ctx.scale(0.5, 0.5) + icon_surf = icon.render_cairo(ctx) + ctx.restore() + + + def render_action(self, ctx, width, height, action): + """ + Renders the action block, along with the icon of the action tool. + """ + ctx.save() + inner_width = width-(BLOCK_CORNERS<<1) + inner_height = height-(BLOCK_CORNERS<<1) + + paint_border = ctx.rel_line_to + filling = cairo.LinearGradient(0, 0, 0, inner_height) + if action not in self.selection: + filling.add_color_stop_rgb(0.0, 0.7, 0.7, 1.0) + filling.add_color_stop_rgb(1.0, 0.1, 0.1, 0.8) + else: + filling.add_color_stop_rgb(0.0, 0.4, 0.4, 0.8) + filling.add_color_stop_rgb(1.0, 0.0, 0.0, 0.5) + tracing = cairo.LinearGradient(0, 0, 0, inner_height) + tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0) + tracing.add_color_stop_rgb(1.0, 0.2, 0.2, 0.2) + + ctx.move_to(BLOCK_CORNERS, 0) + paint_border(inner_width, 0) + ctx.arc(inner_width+BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI2, 0.0) + ctx.arc(inner_width+BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, 0.0, PI2) + ctx.arc(BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, PI2, PI) + ctx.arc(BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI, -PI2) + + ctx.set_source(tracing) + ctx.stroke_preserve() + ctx.set_source(filling) + ctx.fill() + + addon_name = addon.get_name_from_type(type(action)) + # TODO use icon pool + icon_name = addon.get_addon_meta(addon_name)['icon'] + rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name)) + ctx.save() + ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD) + ctx.scale(0.5, 0.5) + icon_surf = rsvg_icon.render_cairo(ctx) + + ctx.restore() + + ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2) + self._render_snapshot(ctx, action) + + ctx.restore() + + def render_event(self, ctx, width, height, event): + """ + Renders the action block, along with the icon of the action tool. + """ + ctx.save() + inner_width = width-(BLOCK_CORNERS<<1) + inner_height = height-(BLOCK_CORNERS<<1) + + filling = cairo.LinearGradient(0, 0, 0, inner_height) + if event not in self.selection: + filling.add_color_stop_rgb(0.0, 1.0, 0.8, 0.6) + filling.add_color_stop_rgb(1.0, 1.0, 0.6, 0.2) + else: + filling.add_color_stop_rgb(0.0, 0.8, 0.6, 0.4) + filling.add_color_stop_rgb(1.0, 0.6, 0.4, 0.1) + tracing = cairo.LinearGradient(0, 0, 0, inner_height) + tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0) + tracing.add_color_stop_rgb(1.0, 0.3, 0.3, 0.3) + + ctx.move_to(BLOCK_CORNERS, 0) + ctx.rel_line_to(inner_width, 0) + ctx.rel_line_to(BLOCK_CORNERS, BLOCK_CORNERS) + ctx.rel_line_to(0, inner_height) + ctx.rel_line_to(-BLOCK_CORNERS, BLOCK_CORNERS) + ctx.rel_line_to(-inner_width, 0) + ctx.rel_line_to(-BLOCK_CORNERS, -BLOCK_CORNERS) + ctx.rel_line_to(0, -inner_height) + ctx.close_path() + + ctx.set_source(tracing) + ctx.stroke_preserve() + ctx.set_source(filling) + ctx.fill() + + addon_name = addon.get_name_from_type(type(event)) + # TODO use icon pool + icon_name = addon.get_addon_meta(addon_name)['icon'] + rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name)) + ctx.save() + ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD) + ctx.scale(0.5, 0.5) + icon_surf = rsvg_icon.render_cairo(ctx) + + ctx.restore() + + ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2) + self._render_snapshot(ctx, event) + + ctx.restore() + + def on_viewer_expose(self, widget, evt, states): + """ + Expose signal handler for the viewer's DrawingArea. + This loops through states and renders every action and transition of + the "happy path". + + @param widget: the gtk.DrawingArea on which to draw + @param evt: the gtk.gdk.Event containing an "expose" event + @param states: a tutorius FiniteStateMachine object to paint + """ + ctx = widget.window.cairo_create() + self.alloc = widget.get_allocation() + ctx.set_source_pixmap(widget.window, + widget.allocation.x, + widget.allocation.y) + + # draw no more than our expose event intersects our child + region = gtk.gdk.region_rectangle(widget.allocation) + r = gtk.gdk.region_rectangle(evt.area) + region.intersect(r) + ctx.region (region) + ctx.clip() + ctx.paint() + + ctx.translate(BLOCK_PADDING, BLOCK_PADDING) + + painter = self._paint_state(ctx, states) + while painter.next(): pass + + if self.click_pos and self.drag_pos: + ctx.set_matrix(cairo.Matrix()) + ctx.rectangle(self.click_pos[0], self.click_pos[1], + self.drag_pos[0]-self.click_pos[0], + self.drag_pos[1]-self.click_pos[1]) + ctx.set_source_rgba(0, 0, 1, 0.5) + ctx.fill_preserve() + ctx.stroke() + + return False + + def _on_click(self, widget, evt): + # the rendering pipeline will work out the click validation process + self.drag_pos = None + self.drag_pos = self.click_pos = evt.get_coords() + widget.queue_draw() + + self.selection = [] + + def _on_drag(self, widget, evt): + self.drag_pos = evt.get_coords() + widget.queue_draw() + + def _on_drag_end(self, widget, evt): + self.click_pos = self.drag_pos = None + widget.queue_draw() + + def _on_key_press(self, widget, evt): + if evt.keyval == gtk.keysyms.BackSpace: + # remove selection + for selected in self.selection: + if isinstance(selected, EventFilter): + self._creator.delete_state() + else: + self._creator.delete_action(selected) + widget.queue_draw() + + |