""" This package contains UI classes related to tutorial authoring. This includes visual display of tools to edit and create tutorials from within the activity itself. """ # Copyright (C) 2009, Tutorius.org # Copyright (C) 2009, Simon Poirier # # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 1 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import gtk.glade import gobject from gettext import gettext as T import uuid import os from sugar.graphics import icon from . import overlayer, gtkutils, vault, addon from .services import ObjectStore from .tutorial import Tutorial from . import viewer from .propwidgets import TextInputDialog 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 = Tutorial('Untitled') self._state = self._tutorial.add_state() self._tutorial.update_transition( transition_name=self._tutorial.INITIAL_TRANSITION_NAME, new_state=self._state) final_event = addon.create( name='MessageButtonNext', message=T('This is the end of this tutorial.') ) self._tutorial.add_transition( state_name=self._state, transition=(final_event, self._tutorial.END), ) 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.metadata = None self._hlmask = overlayer.Rectangle(None, (1.0, 0.0, 0.0, 0.5)) self._activity._overlayer.put(self._hlmask, 0, 0) 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 name @returns: True if successful, otherwise False. """ action_obj = self._tutorial.get_action_dict(self._state)\ .get(action, None) if not action_obj: return False action_obj.exit_editmode() self._tutorial.delete_action(action) self._overview.win.queue_draw() return True 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 self._state in (self._tutorial.INIT, self._tutorial.END) \ or self._tutorial.END in \ self._tutorial.get_following_states_dict(self._state): # last state cannot be removed return False remove_state = self._state next_state = self._tutorial\ .get_following_states_dict(remove_state).keys()[0] self.set_insertion_point(next_state) return bool(self._tutorial.delete_state(remove_state)) def get_insertion_point(self): """ @returns: the current tutorial insertion point. """ return self._state def set_insertion_point(self, state_name): """ Set the tutorial modification point to the specified state. Actions of the state will enter the edit mode. New actions will be inserted to that state and new transisions will shift the current transision to the next state. @param state_name: the name of the state to use as insertion point """ # first is not modifiable, as the auto transition would make changes # pointless. The end state is also pointless to modify, as the tutorial # gets detached. if state_name == self._tutorial.INIT \ or state_name == self._tutorial.END: return for action in self._tutorial.get_action_dict(self._state).values(): action.exit_editmode() self._state = state_name state_actions = self._tutorial.get_action_dict(self._state).values() 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 self._overview.win.queue_draw() 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._tutorial.add_action(self._state, 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. The behaviour of event addition is to push the transition of the current state to the next (newly created state). | v .--------. .-------. .--------. | action |---->| event |---->| action | '--------' '-------' '--------' | .--------. .-----------. v .-------. .--------. | action |--->| new event |-->| event |---->| action | '--------' '-----------' '-------' '--------' The cursor always selects a state (between the action and transition) The result is what the user expects: inserting before an action will effectively shift the next transition to the next state. """ 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) prop.widget_class.run_dialog(self._activity, event, propname) event_filters = self._tutorial.get_transition_dict(self._state) # if not at the end of tutorial if event_filters: old_name = event_filters.keys()[0] old_transition = self._tutorial.delete_transition(old_name) new_state = self._tutorial.add_state( transition_list=(old_transition,) ) self._tutorial.add_transition(state_name=self._state, transition=(event, new_state), ) else: # append empty state only if edit inserting at end of linearized # tutorial. new_state = self._tutorial.add_state() self._tutorial.add_transition(self._state, (event, new_state)) self._overview.win.queue_draw() self.set_insertion_point(new_state) 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, **kwargs): """ Quit editing and cleanup interface artifacts. @param force: force quitting without saving. """ # undo actions so they don't persist through step editing for action in self._tutorial.get_action_dict(self._state).values(): action.exit_editmode() if kwargs.get('force', False): 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): """ Save the currently edited tutorial to bundle, prompting for a name as needed. """ if not self._guid: self._guid = str(uuid.uuid1()) dlg = TextInputDialog(parent=self._overview.win, text=T("Enter a tutorial title."), field=T("Title")) tutorial_name = "" while not tutorial_name: tutorial_name = dlg.pop() dlg.destroy() self._metadata = { vault.INI_GUID_PROPERTY: self._guid, vault.INI_NAME_PROPERTY: tutorial_name, vault.INI_VERSION_PROPERTY: '1', 'activities':{os.environ['SUGAR_BUNDLE_NAME']: os.environ['SUGAR_BUNDLE_VERSION'] }, } vault.Vault.saveTutorial(self._tutorial, self._metadata) 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): """ Palette window for edition tools, including the actions, states and the editable property list of selected actions. """ 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._propedits = [] 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 #Refresh the property editors for prop in self._propedits: prop.refresh_widget() def set_action(self, action): """Setter for the action property.""" if self._action is action: self.refresh_properties() return #Clear the prop box for old_prop in self._propbox.get_children(): self._propbox.remove(old_prop) self._propedits = [] self._action = action if action is None: return for propname in action._props.keys(): row = gtk.HBox() #Label row.pack_start(gtk.Label(T(propname)), False, False, 10) #Value field prop = getattr(type(action), propname) propedit = prop.widget_class(self.__parent, action, propname, self._refresh_action_cb) self._propedits.append(propedit) row.pack_end(propedit.widget) #Add row 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 _refresh_action_cb(self): if self._action is not None: self.__parent._creator._action_refresh_cb(None, None, self._action) # 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: