diff options
Diffstat (limited to 'src/tutorius/creator.py')
-rw-r--r-- | src/tutorius/creator.py | 733 |
1 files changed, 733 insertions, 0 deletions
diff --git a/src/tutorius/creator.py b/src/tutorius/creator.py new file mode 100644 index 0000000..c477056 --- /dev/null +++ b/src/tutorius/creator.py @@ -0,0 +1,733 @@ +""" +This package contains UI classes related to tutorial authoring. +This includes visual display of tools to edit and create tutorials from within +the activity itself. +""" +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Simon Poirier <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 gtk.glade +import gobject +from gettext import gettext as T + +import os +from sugar.graphics import icon +import copy + +from . import overlayer, gtkutils, actions, vault, properties, addon +from . import filters +from .services import ObjectStore +from .core import Tutorial, FiniteStateMachine, State +from . import viewer + +class Creator(object): + """ + Class acting as a bridge between the creator, serialization and core + classes. This contains most of the UI part of the editor. + """ + def __init__(self, activity, tutorial=None): + """ + Instanciate a tutorial creator for the activity. + + @param activity to bind the creator to + @param tutorial an existing tutorial to edit, or None to create one + """ + self._activity = activity + if not tutorial: + self._tutorial = FiniteStateMachine('Untitled') + self._state = State(name='INIT') + self._tutorial.add_state(self._state) + self._state_counter = 1 + else: + self._tutorial = tutorial + # TODO load existing tutorial; unused yet + + self._action_panel = None + self._current_filter = None + self._intro_mask = None + self._intro_handle = None + allocation = self._activity.get_allocation() + self._width = allocation.width + self._height = allocation.height + self._selected_widget = None + self._eventmenu = None + self.tuto = None + self._guid = None + + self._hlmask = overlayer.Rectangle(None, (1.0, 0.0, 0.0, 0.5)) + self._activity._overlayer.put(self._hlmask, 0, 0) + + dlg_width = 300 + dlg_height = 70 + sw = gtk.gdk.screen_width() + sh = gtk.gdk.screen_height() + + self._propedit = ToolBox(self._activity) + self._propedit.tree.signal_autoconnect({ + 'on_quit_clicked': self._cleanup_cb, + 'on_save_clicked': self.save, + 'on_action_activate': self._add_action_cb, + 'on_event_activate': self._add_event_cb, + }) + self._propedit.window.move( + gtk.gdk.screen_width()-self._propedit.window.get_allocation().width, + 100) + + + self._overview = viewer.Viewer(self._tutorial, self) + self._overview.win.set_transient_for(self._activity) + + self._overview.win.move(0, gtk.gdk.screen_height()- \ + self._overview.win.get_allocation().height) + + self._transitions = dict() + + def _update_next_state(self, state, event, next_state): + self._transitions[event] = next_state + + evts = state.get_event_filter_list() + state.clear_event_filters() + for evt, next_state in evts: + state.add_event_filter(evt, self._transitions[evt]) + + def delete_action(self, action): + """ + Removes the first instance of specified action from the tutorial. + + @param action: the action object to remove from the tutorial + @returns: True if successful, otherwise False. + """ + state = self._tutorial.get_state_by_name("INIT") + + while True: + state_actions = state.get_action_list() + for fsm_action in state_actions: + if fsm_action is action: + state.clear_actions() + if state is self._state: + fsm_action.exit_editmode() + state_actions.remove(fsm_action) + self.set_insertion_point(state.name) + for keep_action in state_actions: + state.add_action(keep_action) + return True + + ev_list = state.get_event_filter_list() + if ev_list: + state = self._tutorial.get_state_by_name(ev_list[0][1]) + continue + + return False + + def delete_state(self): + """ + Remove current state. + Limitation: The last state cannot be removed, as it doesn't have + any transitions to remove anyway. + + @returns: True if successful, otherwise False. + """ + if not self._state.get_event_filter_list(): + # last state cannot be removed + return False + + state = self._tutorial.get_state_by_name("INIT") + ev_list = state.get_event_filter_list() + if state is self._state: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + self.set_insertion_point(next_state.name) + self._tutorial.remove_state(state.name) + self._tutorial.remove_state(next_state.name) + next_state.name = "INIT" + self._tutorial.add_state(next_state) + return True + + # loop to repair links from deleted state + while ev_list: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + if next_state is self._state: + # the tutorial will flush the event filters. We'll need to + # clear and re-add them. + self._tutorial.remove_state(self._state.name) + state.clear_event_filters() + self._update_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1]) + for ev, next_state in ev_list: + state.add_event_filter(ev, next_state) + + self.set_insertion_point(ev_list[0][1]) + return True + + state = next_state + ev_list = state.get_event_filter_list() + return False + + def get_insertion_point(self): + return self._state.name + + def set_insertion_point(self, state_name): + for action in self._state.get_action_list(): + action.exit_editmode() + self._state = self._tutorial.get_state_by_name(state_name) + self._overview.win.queue_draw() + state_actions = self._state.get_action_list() + for action in state_actions: + action.enter_editmode() + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + + if state_actions: + self._propedit.action = state_actions[0] + else: + self._propedit.action = None + + + def _evfilt_cb(self, menuitem, event): + """ + This will get called once the user has selected a menu item from the + event filter popup menu. This should add the correct event filter + to the FSM and increment states. + """ + # undo actions so they don't persist through step editing + for action in self._state.get_action_list(): + action.exit_editmode() + self._hlmask.covered = None + self._propedit.action = None + self._activity.queue_draw() + + def _intro_cb(self, widget, evt): + """ + Callback for capture of widget events, when in introspect mode. + """ + if evt.type == gtk.gdk.BUTTON_PRESS: + # widget has focus, let's hilight it + win = gtk.gdk.display_get_default().get_window_at_pointer() + click_wdg = win[0].get_user_data() + if not click_wdg.is_ancestor(self._activity._overlayer): + # as popups are not (yet) supported, it would break + # badly if we were to play with a widget not in the + # hierarchy. + return + for hole in self._intro_mask.pass_thru: + self._intro_mask.mask(hole) + self._intro_mask.unmask(click_wdg) + self._selected_widget = gtkutils.raddr_lookup(click_wdg) + + if self._eventmenu: + self._eventmenu.destroy() + self._eventmenu = gtk.Menu() + menuitem = gtk.MenuItem(label=type(click_wdg).__name__) + menuitem.set_sensitive(False) + self._eventmenu.append(menuitem) + self._eventmenu.append(gtk.MenuItem()) + + for item in gobject.signal_list_names(click_wdg): + menuitem = gtk.MenuItem(label=item) + menuitem.connect("activate", self._evfilt_cb, item) + self._eventmenu.append(menuitem) + self._eventmenu.show_all() + self._eventmenu.popup(None, None, None, evt.button, evt.time) + self._activity.queue_draw() + + def _add_action_cb(self, widget, path): + """Callback for the action creation toolbar tool""" + action_type = self._propedit.actions_list[path][ToolBox.ICON_NAME] + action = addon.create(action_type) + action.enter_editmode() + self._state.add_action(action) + # FIXME: replace following with event catching + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + self._overview.win.queue_draw() + + def _add_event_cb(self, widget, path): + """Callback for the event creation toolbar tool""" + event_type = self._propedit.events_list[path][ToolBox.ICON_NAME] + event = addon.create(event_type) + addonname = type(event).__name__ + meta = addon.get_addon_meta(addonname) + for propname in meta['mandatory_props']: + prop = getattr(type(event), propname) + if isinstance(prop, properties.TUAMProperty): + selector = WidgetSelector(self._activity) + setattr(event, propname, selector.select()) + elif isinstance(prop, properties.TEventType): + try: + dlg = SignalInputDialog(self._activity, + text="Mandatory property", + field=propname, + addr=event.object_id) + setattr(event, propname, dlg.pop()) + except AttributeError: + pass + elif isinstance(prop, properties.TStringProperty): + dlg = TextInputDialog(self._activity, + text="Mandatory property", + field=propname) + setattr(event, propname, dlg.pop()) + else: + raise NotImplementedError() + + event_filters = self._state.get_event_filter_list() + if event_filters: + # linearize tutorial by inserting state + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + self._state.clear_event_filters() + for evt_filt, next_state in event_filters: + new_state.add_event_filter(evt_filt, next_state) + self._update_next_state(self._state, event, new_state.name) + next_state = new_state.name + # blocks are shifted, full redraw is necessary + self._overview.win.queue_draw() + else: + # append empty state only if edit inserting at end of linearized + # tutorial. + self._update_next_state(self._state, event, str(self._state_counter)) + next_state = str(self._state_counter) + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + + self._state.add_event_filter(event, next_state) + self._tutorial.add_state(new_state) + self._overview.win.queue_draw() + + self.set_insertion_point(new_state.name) + + def _action_refresh_cb(self, widget, evt, action): + """ + Callback for refreshing properties values and notifying the + property dialog of the new values. + """ + action.exit_editmode() + action.enter_editmode() + self._activity.queue_draw() + # TODO: replace following with event catching + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + self._propedit.action = action + + self._overview.win.queue_draw() + + def _cleanup_cb(self, *args): + """ + Quit editing and cleanup interface artifacts. + """ + # undo actions so they don't persist through step editing + for action in self._state.get_action_list(): + action.exit_editmode() + + dialog = gtk.MessageDialog( + parent=self._activity, + flags=gtk.DIALOG_MODAL, + type=gtk.MESSAGE_QUESTION, + buttons=gtk.BUTTONS_YES_NO, + message_format=T('Do you want to save before stopping edition?')) + do_save = dialog.run() + dialog.destroy() + if do_save == gtk.RESPONSE_YES: + self.save() + + # remove UI remains + self._hlmask.covered = None + self._activity._overlayer.remove(self._hlmask) + self._hlmask.destroy() + self._hlmask = None + self._propedit.destroy() + self._overview.destroy() + self._activity.queue_draw() + del self._activity._creator + + def save(self, widget=None): + if not self.tuto: + dlg = TextInputDialog(self._activity, + text=T("Enter a tutorial title."), + field=T("Title")) + tutorialName = "" + while not tutorialName: tutorialName = dlg.pop() + dlg.destroy() + + # prepare tutorial for serialization + self.tuto = Tutorial(tutorialName, self._tutorial) + bundle = vault.TutorialBundler(self._guid) + self._guid = bundle.Guid + bundle.write_metadata_file(self.tuto) + bundle.write_fsm(self._tutorial) + + + def launch(*args, **kwargs): + """ + Launch and attach a creator to the currently running activity. + """ + activity = ObjectStore().activity + if not hasattr(activity, "_creator"): + activity._creator = Creator(activity) + launch = staticmethod(launch) + +class ToolBox(object): + ICON_LABEL = 0 + ICON_IMAGE = 1 + ICON_NAME = 2 + ICON_TIP = 3 + def __init__(self, parent): + super(ToolBox, self).__init__() + self.__parent = parent + sugar_prefix = os.getenv("SUGAR_PREFIX",default="/usr") + glade_file = os.path.join(sugar_prefix, 'share', 'tutorius', + 'ui', 'creator.glade') + self.tree = gtk.glade.XML(glade_file) + self.window = self.tree.get_widget('mainwindow') + self._propbox = self.tree.get_widget('propbox') + + self.window.set_transient_for(parent) + + self._action = None + self.actions_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.actions_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + self.events_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.events_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + + for toolname in addon.list_addons(): + meta = addon.get_addon_meta(toolname) + iconfile = gtk.Image() + iconfile.set_from_file(icon.get_icon_file_name(meta['icon'])) + img = iconfile.get_pixbuf() + label = format_multiline(meta['display_name']) + + if meta['type'] == addon.TYPE_ACTION: + self.actions_list.append((label, img, toolname, meta['display_name'])) + else: + self.events_list.append((label, img, toolname, meta['display_name'])) + + iconview_action = self.tree.get_widget('iconview1') + iconview_action.set_model(self.actions_list) + iconview_action.set_text_column(self.ICON_LABEL) + iconview_action.set_pixbuf_column(self.ICON_IMAGE) + iconview_action.set_tooltip_column(self.ICON_TIP) + iconview_event = self.tree.get_widget('iconview2') + iconview_event.set_model(self.events_list) + iconview_event.set_text_column(self.ICON_LABEL) + iconview_event.set_pixbuf_column(self.ICON_IMAGE) + iconview_event.set_tooltip_column(self.ICON_TIP) + + self.window.show() + + def destroy(self): + """ clean and free the toolbox """ + self.window.destroy() + + def refresh_properties(self): + """Refresh property values from the selected action.""" + if self._action is None: + return + props = self._action._props.keys() + for propnum in xrange(len(props)): + row = self._propbox.get_children()[propnum] + propname = props[propnum] + prop = getattr(type(self._action), propname) + propval = getattr(self._action, propname) + if isinstance(prop, properties.TStringProperty): + propwdg = row.get_children()[1] + propwdg.get_buffer().set_text(propval) + elif isinstance(prop, properties.TUAMProperty): + propwdg = row.get_children()[1] + propwdg.set_label(propval) + elif isinstance(prop, properties.TIntProperty): + propwdg = row.get_children()[1] + propwdg.set_value(propval) + elif isinstance(prop, properties.TArrayProperty): + propwdg = row.get_children()[1] + for i in xrange(len(propval)): + entry = propwdg.get_children()[i] + entry.set_text(str(propval[i])) + else: + propwdg = row.get_children()[1] + propwdg.set_text(str(propval)) + + def set_action(self, action): + """Setter for the action property.""" + if self._action is action: + self.refresh_properties() + return + for old_prop in self._propbox.get_children(): + self._propbox.remove(old_prop) + + self._action = action + if action is None: + return + for propname in action._props.keys(): + row = gtk.HBox() + row.pack_start(gtk.Label(T(propname)), False, False, 10) + prop = getattr(type(action), propname) + propval = getattr(action, propname) + if isinstance(prop, properties.TStringProperty): + propwdg = gtk.TextView() + propwdg.get_buffer().set_text(propval) + propwdg.connect_after("focus-out-event", \ + self._str_prop_changed, action, propname) + elif isinstance(prop, properties.TUAMProperty): + propwdg = gtk.Button(propval) + propwdg.connect_after("clicked", \ + self._uam_prop_changed, action, propname) + elif isinstance(prop, properties.TIntProperty): + adjustment = gtk.Adjustment(value=propval, + lower=prop.lower_limit.limit, + upper=prop.upper_limit.limit, + step_incr=1) + propwdg = gtk.SpinButton(adjustment=adjustment) + propwdg.connect_after("focus-out-event", \ + self._int_prop_changed, action, prop) + elif isinstance(prop, properties.TArrayProperty): + propwdg = gtk.HBox() + for i in xrange(len(propval)): + entry = gtk.Entry() + propwdg.pack_start(entry) + entry.connect_after("focus-out-event", \ + self._list_prop_changed, action, propname, i) + else: + propwdg = gtk.Entry() + propwdg.set_text(str(propval)) + row.pack_end(propwdg) + self._propbox.pack_start(row, expand=False) + self._propbox.show_all() + self.refresh_properties() + + def get_action(self): + """Getter for the action property""" + return self._action + action = property(fset=set_action, fget=get_action, doc=\ + "Action to be edited through introspection.") + + def _list_prop_changed(self, widget, evt, action, propname, idx): + try: + #Save props as tuples so that they can be hashed + attr = list(getattr(action, propname)) + attr[idx] = int(widget.get_text()) + setattr(action, propname, tuple(attr)) + except ValueError: + widget.set_text(str(getattr(action, propname)[idx])) + self.__parent._creator._action_refresh_cb(None, None, action) + def _uam_prop_changed(self, widget, action, propname): + selector = WidgetSelector(self.__parent) + selection = selector.select() + setattr(action, propname, selection) + self.__parent._creator._action_refresh_cb(None, None, action) + def _str_prop_changed(self, widget, evt, action, propname): + buf = widget.get_buffer() + setattr(action, propname, buf.get_text(buf.get_start_iter(), buf.get_end_iter())) + self.__parent._creator._action_refresh_cb(None, None, action) + def _int_prop_changed(self, widget, evt, action, prop): + setattr(action, propname, widget.get_value_as_int()) + self.__parent._creator._action_refresh_cb(None, None, action) + + +class WidgetSelector(object): + """ + Allow selecting a widget from within a window without interrupting the + flow of the current call. + + The selector will run on the specified window until either a widget + is selected or abort() gets called. + """ + def __init__(self, window): + super(WidgetSelector, self).__init__() + self.window = window + self._intro_mask = None + self._intro_handle = None + self._select_handle = None + self._prelight = None + + def select(self): + """ + Starts selecting a widget, by grabbing control of the mouse and + highlighting hovered widgets until one is clicked. + @returns: a widget address or None + """ + if not self._intro_mask: + self._prelight = None + self._intro_mask = overlayer.Mask(catch_events=True) + self._select_handle = self._intro_mask.connect_after( + "button-press-event", self._end_introspect) + self._intro_handle = self._intro_mask.connect_after( + "motion-notify-event", self._intro_cb) + self.window._overlayer.put(self._intro_mask, 0, 0) + self.window._overlayer.queue_draw() + + while bool(self._intro_mask) and not gtk.main_iteration(): + pass + + return gtkutils.raddr_lookup(self._prelight) + + def _end_introspect(self, widget, evt): + if evt.type == gtk.gdk.BUTTON_PRESS and self._prelight: + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + # for some reason, gtk may not redraw after this unless told to. + self.window.queue_draw() + + def _intro_cb(self, widget, evt): + """ + Callback for capture of widget events, when in introspect mode. + """ + # widget has focus, let's hilight it + win = gtk.gdk.display_get_default().get_window_at_pointer() + if not win: + return + click_wdg = win[0].get_user_data() + if not click_wdg.is_ancestor(self.window._overlayer): + # as popups are not (yet) supported, it would break + # badly if we were to play with a widget not in the + # hierarchy. + return + for hole in self._intro_mask.pass_thru: + self._intro_mask.mask(hole) + self._intro_mask.unmask(click_wdg) + self._prelight = click_wdg + + self.window.queue_draw() + + def abort(self): + """ + Ends the selection. The control will return to the select() caller + with a return value of None, as selection was aborted. + """ + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + self._prelight = None + +class SignalInputDialog(gtk.MessageDialog): + def __init__(self, parent, text, field, addr): + """ + Create a gtk signal selection dialog. + + @param parent: the parent window this dialog should stay over. + @param text: the title of the dialog. + @param field: the field description of the dialog. + @param addr: the widget address from which to fetch signal list. + """ + gtk.MessageDialog.__init__(self, parent, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + gtk.MESSAGE_QUESTION, + gtk.BUTTONS_OK, + None) + self.set_markup(text) + self.model = gtk.ListStore(str) + widget = gtkutils.find_widget(parent, addr) + for signal_name in gobject.signal_list_names(widget): + self.model.append(row=(signal_name,)) + self.entry = gtk.ComboBox(self.model) + cell = gtk.CellRendererText() + self.entry.pack_start(cell) + self.entry.add_attribute(cell, 'text', 0) + hbox = gtk.HBox() + lbl = gtk.Label(field) + hbox.pack_start(lbl, False) + hbox.pack_end(self.entry) + self.vbox.pack_end(hbox, True, True) + self.show_all() + + def pop(self): + """ + Show the dialog. It will run in it's own loop and return control + to the caller when a signal has been selected. + + @returns: a signal name or None if no signal was selected + """ + self.run() + self.hide() + iter = self.entry.get_active_iter() + if iter: + text = self.model.get_value(iter, 0) + return text + return None + + def _dialog_done_cb(self, entry, response): + self.response(response) + +class TextInputDialog(gtk.MessageDialog): + def __init__(self, parent, text, field): + gtk.MessageDialog.__init__(self, parent, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + gtk.MESSAGE_QUESTION, + gtk.BUTTONS_OK, + None) + self.set_markup(text) + self.entry = gtk.Entry() + self.entry.connect("activate", self._dialog_done_cb, gtk.RESPONSE_OK) + hbox = gtk.HBox() + lbl = gtk.Label(field) + hbox.pack_start(lbl, False) + hbox.pack_end(self.entry) + self.vbox.pack_end(hbox, True, True) + self.show_all() + + def pop(self): + self.run() + self.hide() + text = self.entry.get_text() + return text + + def _dialog_done_cb(self, entry, response): + self.response(response) + +# The purpose of this function is to reformat text, as current IconView +# implentation does not insert carriage returns on long lines. +# To preserve layout, this call reformat text to fit in small space under an +# icon. +def format_multiline(text, length=10, lines=3, line_separator='\n'): + """ + Reformat a text to fit in a small space. + + @param length: maximum char per line + @param lines: maximum number of lines + """ + words = text.split(' ') + line = list() + return_val = [] + linelen = 0 + + for word in words: + t_len = linelen+len(word) + if t_len < length: + line.append(word) + linelen = t_len+1 # count space + else: + if len(return_val)+1 < lines: + return_val.append(' '.join(line)) + line = list() + linelen = 0 + line.append(word) + else: + return_val.append(' '.join(line+['...'])) + return line_separator.join(return_val) + + return_val.append(' '.join(line)) + return line_separator.join(return_val) + + +# vim:set ts=4 sts=4 sw=4 et: |