""" 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 . 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: