""" 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 pickle import uuid import os from sugar.graphics import icon, style import jarabe.frame from . import overlayer, gtkutils, vault, addon from .tutorial import Tutorial from . import viewer from .propwidgets import TextInputDialog from . import TProbe from functools import partial from dbus import SessionBus from dbus.service import method, Object, BusName from .dbustools import ignore from jarabe.model import bundleregistry import logging LOGGER = logging.getLogger("creator") BUS_PATH = "/org/tutorius/Creator" BUS_NAME = "org.tutorius.Creator" def default_creator(): """ The Creator class is a singleton. There can never be more than one creator at a time. This method returns a new instance only if none already exists. Else, the existing instance is returned. """ return Creator._instance def get_creator_proxy(): """ Returns a Creator dbus proxy for inter-process events. """ bus = SessionBus() proxy = bus.get_object(BUS_NAME, BUS_PATH) return proxy class Creator(Object): """ Class acting as a controller for the tutorial edition. """ _instance = None def __init__(self, probe_manager): """ Creates the instance of the creator. It is assumed this will be called only once, by the Service. @param probe_manager The Probe Manager """ bus_name = BusName(BUS_NAME, bus=SessionBus()) Object.__init__(self, bus_name, BUS_PATH) self.tuto = None self.is_authoring = False if Creator._instance: raise RuntimeError("Creator was already instanciated") Creator._instance = self self._probe_mgr = probe_manager self._installed_actions = list() def start_authoring(self, tutorial=None): """ Start authoring a tutorial. @type tutorial: str or None @param tutorial: the unique identifier to an existing tutorial to modify, or None to create a new one. """ if self.is_authoring: raise Exception("Already authoring") self.is_authoring = True 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.', source=self._probe_mgr.currentActivity) ) 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._selected_widget = None self._eventmenu = None self.tuto = None self._guid = None self.metadata = None frame = jarabe.frame.get_view() self._propedit = ToolBox(None) 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\ -style.GRID_CELL_SIZE, style.GRID_CELL_SIZE) self._propedit.window.connect('enter-notify-event', frame._enter_notify_cb) self._propedit.window.connect('leave-notify-event', frame._leave_notify_cb) self._propedit.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DOCK) self._overview = viewer.Viewer(self._tutorial, self) self._overview.win.set_transient_for(frame._bottom_panel) self._overview.win.connect('enter-notify-event', frame._enter_notify_cb) self._overview.win.connect('leave-notify-event', frame._leave_notify_cb) self._overview.win.move(style.GRID_CELL_SIZE, gtk.gdk.screen_height()-style.GRID_CELL_SIZE \ -self._overview.win.get_allocation().height) self._overview.win.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DOCK) 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 self._probe_mgr.uninstall(action_obj.address) 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 transitions will shift the current transition to the next state. @param state_name: the name of the state to use as insertion point """ # Check if already in state to avoid reinstalling actions needlessly if self._state == state_name: return # 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._installed_actions: self._probe_mgr.uninstall(action.address, is_editing=True) self._installed_actions = [] self._state = state_name state_actions = self._tutorial.get_action_dict(self._state).values() for action in state_actions: return_cb = partial(self._action_installed_cb, action) self._probe_mgr.install(action, action_installed_cb=return_cb, error_cb=self._dbus_exception, is_editing=True, editing_cb=self.update_addon_property) if state_actions: # I'm really lazy right now and to keep things simple I simply # always select the first action when # we change state. we should really select the clicked block # in the overview instead. FIXME self._propedit.action = state_actions[0] else: self._propedit.action = None self._overview.win.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] LOGGER.debug("Creator :: Adding an action = %s"%(action_type)) action = addon.create(action_type) # Configure the action prior to installing it # Currently, this consists of writing its source action.source = self._probe_mgr.currentActivity return_cb = partial(self._action_installed_cb, action) self._probe_mgr.install(action, action_installed_cb=return_cb, error_cb=self._dbus_exception, is_editing=True, editing_cb=self.update_addon_property) self._tutorial.add_action(self._state, action) self._propedit.action = 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 = self._probe_mgr.create_event(event_type, event_created_cb=partial(self._event_created, event_type)) def _event_created(self, event_type, event): """ Callback to execute when the creation of a new event is complete. @param event_type The type of event that was created @param event The event that was instanciated """ LOGGER.debug("Creator :: _event_created, now setting source and adding inside tutorial") # Configure the event prior to installing it # Currently, this consists of writing its source event.source = self._probe_mgr.currentActivity 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 properties_changed(self, action): LOGGER.debug("Creator :: properties_changed for action at address %s"%(action.address)) address = action.address self._probe_mgr.update(address, action, is_editing=True) self._propedit.action = action def _action_refresh_cb(self, widget, evt, action): """ Callback for refreshing properties values and notifying the property dialog of the new values. """ # TODO : replace with update self._probe_mgr.uninstall(action.address, is_editing=True) return_cb = partial(self._action_installed_cb, action) self._probe_mgr.install(action, action_installed_cb=return_cb, error_cb=self._dbus_exception, is_editing=True, editing_cb=self.update_addon_property) 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(): self._probe_mgr.uninstall(action.address, is_editing=True) # TODO : Support quit cancellation - right now,every time we execute this, # we will forcibly end edition afterwards. It would be nice to keep creating if not kwargs.get('force', False): # TODO : Move the dialog in the middle of the screen dialog = gtk.MessageDialog( parent=self._overview.win, 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._propedit.destroy() self._overview.destroy() self.is_authoring = False 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', } related_activities_dict ={} activity_set = set() for state_name in self._tutorial.get_state_dict().keys(): for action in self._tutorial.get_action_dict(state_name).values(): if action.source is not None: activity_set.add(action.source) for event,next_state in self._tutorial.get_transition_dict(state_name).values(): if event.source is not None: activity_set.add(event.source) reg = bundleregistry.get_registry() for activity_name in activity_set: bundle = reg.get_bundle(activity_name) if bundle is not None: related_activities_dict[activity_name] = str(bundle.get_activity_version()) self._metadata['activities'] = dict(related_activities_dict) vault.Vault.saveTutorial(self._tutorial, self._metadata) def launch(self, *args): assert False, "REMOVE THIS CALL!!!" launch = staticmethod(launch) def _action_installed_cb(self, action, address): """ This is a callback intented to be use to receive actions addresses after they are installed. @param address: the address of the newly installed action """ action.address = address self._installed_actions.append(action) def _dbus_exception(self, event, exception): """ This is a callback intented to be use to receive exceptions on remote DBUS calls. @param exception: the exception thrown by the remote process """ LOGGER.debug("Creator :: Got exception -> %s"%(str(exception))) @method(BUS_NAME, in_signature='', out_signature='b') def get_authoring_state(self): """ @returns True if the creator is being executed right now, False otherwise. """ return self.is_authoring def update_addon_property(self, addon_address, diff_dict): """ Updates the properties on an addon. @param addon_address The address of the addon that has the property @param diff_dict The updates to apply to the property dict. This is treated as a partial update to the addon's dictionary and contains at least one property value pair @returns True if the property was updated, False otherwise """ # Look up the registered addresses inside the installed actions for action in self._installed_actions: # If this is the correct action if action.address == addon_address: LOGGER.debug("Creator :: Updating address %s with new values %s"%(addon_address, str(diff_dict))) # Update its property with the new value action._props.update(diff_dict) # Update the property edition dialog with it self._propedit.action = action return True 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.window.modify_bg(gtk.STATE_NORMAL, style.COLOR_TOOLBAR_GREY.get_gdk_color()) self._propbox = self.tree.get_widget('propbox') self._propedits = [] self.window.set_transient_for(parent) self.window.set_keep_above(True) 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, probe_mgr=default_creator()._probe_mgr) 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: default_creator().properties_changed(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: