""" 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.gdk import gtk.glade import gobject from gettext import gettext as T 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.core import Tutorial, FiniteStateMachine, State from sugar.tutorius 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.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.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 _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: