From e20cfb534e276fa7762980bbdd6d633a7ce99ccc Mon Sep 17 00:00:00 2001 From: mike Date: Thu, 19 Mar 2009 20:06:38 +0000 Subject: Merge branch 'tutorial_toolkit' of ssh://mike@bobthebuilder.mine.nu:8080/home/git into tutorial_toolkit Conflicts: source/external/source/sugar-toolkit/src/sugar/tutorius/core.py --- diff --git a/src/sugar/activity/activity.py b/src/sugar/activity/activity.py index c5dca45..21e38f6 100644 --- a/src/sugar/activity/activity.py +++ b/src/sugar/activity/activity.py @@ -76,6 +76,7 @@ from sugar.graphics.xocolor import XoColor from sugar.datastore import datastore from sugar.session import XSMPClient from sugar import wm +from sugar.tutorius.services import ObjectStore _ = lambda msg: gettext.dgettext('sugar-toolkit', msg) @@ -181,18 +182,6 @@ class ActivityToolbar(gtk.Toolbar): self._updating_share = False - def __tutorial_changed_cb(self, combo): - if self._current_tutorial: - self._current_tutorial.detach() - - model = self.tutorials.combo.get_model() - it = self.tutorials.combo.get_active_iter() - (key, ) = model.get(it, 0) - t = self._activity.get_tutorials.get(key,None) - if t: - self._current_tutorial = t - self._current_tutorial.attach(self._activity) - def __share_changed_cb(self, combo): if self._updating_share: return @@ -508,6 +497,8 @@ class Activity(Window, gtk.Container): """ Window.__init__(self) + ObjectStore().activity = self + # process titles will only show 15 characters # but they get truncated anyway so if more characters # are supported in the future we will get a better view diff --git a/src/sugar/graphics/window.py b/src/sugar/graphics/window.py index 1ad2bca..a17ebcc 100644 --- a/src/sugar/graphics/window.py +++ b/src/sugar/graphics/window.py @@ -23,6 +23,7 @@ import gobject import gtk from sugar.graphics.icon import Icon +from sugar.tutorius.overlayer import Overlayer class UnfullscreenButton(gtk.Window): @@ -97,9 +98,13 @@ class Window(gtk.Window): self._hbox.pack_start(self._event_box) self._event_box.show() - self.add(self._vbox) +## self.add(self._vbox) self._vbox.show() + self._overlayer = Overlayer(self._vbox) + self.add(self._overlayer) + self._overlayer.show() + self._is_fullscreen = False self._unfullscreen_button = UnfullscreenButton() self._unfullscreen_button.set_transient_for(self) diff --git a/src/sugar/tutorius/Makefile.am b/src/sugar/tutorius/Makefile.am index d6ce0f1..1fb11e1 100644 --- a/src/sugar/tutorius/Makefile.am +++ b/src/sugar/tutorius/Makefile.am @@ -5,4 +5,7 @@ sugar_PYTHON = \ dialog.py \ actions.py \ gtkutils.py \ - filters.py + filters.py \ + services.py \ + overlayer.py \ + editor.py diff --git a/src/sugar/tutorius/actions.py b/src/sugar/tutorius/actions.py index 58a4216..12de298 100644 --- a/src/sugar/tutorius/actions.py +++ b/src/sugar/tutorius/actions.py @@ -16,8 +16,13 @@ """ This module defines Actions that can be done and undone on a state """ +from gettext import gettext as _ +from sugar.tutorius import gtkutils from dialog import TutoriusDialog +import overlayer +from sugar.tutorius.editor import WidgetIdentifier +from sugar.tutorius.services import ObjectStore class Action(object): @@ -25,7 +30,7 @@ class Action(object): def __init__(self): object.__init__(self) - def do(self): + def do(self, **kwargs): """ Perform the action """ @@ -38,49 +43,12 @@ class Action(object): pass #Should raise NotImplemented? -class DoOnceMixin(object): - """Mixin class used to have an action be called only on the first do(). - - To use this mixin, create a new class that derives from this and from - the action you want executed only once. - - class ConcreteOnceAction(DoOnceMixin, ConcreteAction): - pass +class OnceWrapper(object): + """ + Wraps a class to perform an action once only This ConcreteActions's do() method will only be called on the first do() and the undo() will be callable after do() has been called - - Maybe it would be better off just using a wrapper class instead of this. - - But it was fun to play with. - - Did I mention the wrapper is 25 lines "south" of here? Besides, this mixin - class fails at __init__ ..... mixins... right....narwhals! - """ - def __init__(self ): - super(DoOnceMixin, self).__init__() - self._called = False - self._need_undo = False - - def do(self): - """ - Do the action only on the first time - """ - if not self._called: - self._called = True - super(DoOnceMixin, self).do() - self._need_undo = True - - def undo(self): - """ - Undo the action if it's been done - """ - if self._need_undo: - super(DoOnceMixin, self).undo() - self._need_undo = False - -class OnceWrapper(object): - """Wraps a class to perform an action once only """ def __init__(self, action): self._action = action @@ -95,7 +63,7 @@ class OnceWrapper(object): self._called = True self._action.do() self._need_undo = True - + def undo(self): """ Undo the action if it's been done @@ -105,10 +73,16 @@ class OnceWrapper(object): self._need_undo = False class DialogMessage(Action): - """Show a dialog!""" - def __init__(self, message): + """ + Shows a dialog with a given text, at the given position on the screen. + + @param message A string to display to the user + @param pos A list of the form [x, y] + """ + def __init__(self, message, pos=[0,0]): super(DialogMessage, self).__init__() self._message = message + self.position = pos self._dialog = None def do(self): @@ -118,6 +92,7 @@ class DialogMessage(Action): self._dialog = TutoriusDialog(self._message) self._dialog.set_button_clicked_cb(self._dialog.close_self) self._dialog.set_modal(False) + self._dialog.move(self.position[0], self.position[1]) self._dialog.show() def undo(self): @@ -127,9 +102,72 @@ class DialogMessage(Action): if self._dialog: self._dialog.destroy() self._dialog = None + + +class BubbleMessage(Action): + """ + Shows a dialog with a given text, at the given position on the screen. + + @param message A string to display to the user + @param pos A list of the form [x, y] + @param speaker treeish representation of the speaking widget + """ + def __init__(self, message, pos=[0,0], speaker=None, tailpos=None): + Action.__init__(self) + self._message = message + self.position = pos + + self.overlay = None + self._bubble = None + self._speaker = None + self._tailpos = tailpos + + + def do(self): + """ + Show the dialog + """ + # get or inject overlayer + self.overlay = ObjectStore().activity._overlayer + # FIXME: subwindows, are left to overlap this. This behaviour is + # undesirable. subwindows (i.e. child of top level windows) should be + # handled either by rendering over them, or by finding different way to + # draw the overlay. + + if not self._bubble: + x, y = self.position + # TODO: tails are relative to tailpos. They should be relative to + # the speaking widget. Same of the bubble position. + self._bubble = overlayer.TextBubble(text=self._message, + tailpos=self._tailpos) + self._bubble.show() + self.overlay.put(self._bubble, x, y) + self.overlay.queue_draw() + + def undo(self): + """ + Destroy the dialog + """ + if self._bubble: + self._bubble.destroy() + self._bubble = None +class WidgetIdentifyAction(Action): + def __init__(self): + self.activity = None + self._dialog = None + + def do(self): + os = ObjectStore() + if os.activity: + self.activity = os.activity + + self._dialog = WidgetIdentifier(self.activity) + self._dialog.show() + + + def undo(self): + if self._dialog: + self._dialog.destroy() -class OnceDialogMessage(DoOnceMixin, DialogMessage): - """Broken!""" - pass diff --git a/src/sugar/tutorius/core.py b/src/sugar/tutorius/core.py index 85929db..14bb751 100644 --- a/src/sugar/tutorius/core.py +++ b/src/sugar/tutorius/core.py @@ -26,6 +26,7 @@ import logging from sugar.tutorius.dialog import TutoriusDialog from sugar.tutorius.gtkutils import find_widget +from sugar.tutorius.services import ObjectStore logger = logging.getLogger("tutorius") @@ -60,19 +61,17 @@ class Tutorial (object): if self.activity: self.detach() self.activity = activity + ObjectStore().activity = activity + ObjectStore().tutorial = self self.state_machine.set_state("INIT") def detach(self): """ Detach from the current activity """ - #Remove handlers - for eventfilter in self.state_machine.get(self.state,{}).get("EventFilters",()): - eventfilter.remove_handlers() - - #Undo actions - for act in self.state_machine.get(self.state,{}).get("Actions",()): - act.undo() + + # Uninstall the whole FSM + self.state_machine.teardown() #FIXME There should be some amount of resetting done here... self.activity = None @@ -82,31 +81,10 @@ class Tutorial (object): """ Switch to a new state """ -## if not self.state_machine.has_key(name): -## return logger.debug("====NEW STATE: %s====" % name) -## #Remove handlers -## for eventfilter in self.state_machine.get(self.state,{}).get("EventFilters",()): -## eventfilter.remove_handlers() - -## #Undo actions -## for act in self.state_machine.get(self.state,{}).get("Actions",()): -## act.undo() self.state_machine.set_state(name) - -## #Switch to new state -## self.state = name -## newstate = self.state_machine.get(name) -## #Register handlers for eventfilters -## for eventfilter in newstate["EventFilters"]: -## eventfilter.install_handlers(self._eventfilter_state_done, -## activity=self.activity) - -## #Do actions -## for act in newstate.get("Actions",()): -## act.do() # Currently unused -- equivalent function is in each state def _eventfilter_state_done(self, eventfilter): @@ -119,21 +97,6 @@ class Tutorial (object): #Swith to the next state pointed by the eventfilter self.set_state(eventfilter.get_next_state()) -# def register_signal(self, handler, obj_fqdn, signal_name): -# """Register a signal handler onto a specific widget -# @param handler function to attach as a handler -# @param obj_fqdn fqdn-style object name -# @param signal_name signal name to connect to -# -# Side effects: -# the object found and the handler id obtained by connect() are -# appended in self.handlers -# """ -# obj = find_widget(self.activity, obj_fqdn) -# self.handlers.append( \ -# (obj, obj.connect(signal_name, handler, (signal_name, obj_fqdn) ))\ -# ) - class State: """ This is a step in a tutorial. The state represents a collection of actions diff --git a/src/sugar/tutorius/editor.py b/src/sugar/tutorius/editor.py new file mode 100644 index 0000000..1a1eb61 --- /dev/null +++ b/src/sugar/tutorius/editor.py @@ -0,0 +1,115 @@ +# Copyright (C) 2009, Tutorius.org +# Greatly influenced by sugar/activity/namingalert.py +# +# 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 2 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 +import gobject +import hippo +import gconf + +from gettext import gettext as _ + +class WidgetIdentifier(gtk.Window): + """ + Tool that allows identifying widgets + """ + __gtype_name__ = 'TutoriusWidgetIdentifier' + + def __init__(self, activity): + gtk.Window.__init__(self) + + self._activity = activity + self._handlers = [] + + self.set_decorated(False) + self.set_resizable(False) + self.set_modal(False) + + self.connect('realize', self.__realize_cb) + + self._expander = gtk.Expander(_("Widget Identifier")) + self._expander.set_expanded(True) + self.add(self._expander) + self._expander.connect("notify::expanded", self.__expander_cb) + + self._expander.show() + + vbox = gtk.VBox() + self._expander.add(vbox) + vbox.show() + + + self.logview = gtk.TextView() + self.logview.set_editable(False) + self.logview.set_cursor_visible(False) + self.logview.set_wrap_mode(gtk.WRAP_NONE) + self._textbuffer = self.logview.get_buffer() + + sw = gtk.ScrolledWindow() + sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + sw.add(self.logview) + self.logview.show() + + vbox.pack_start(sw) + sw.show() + + from sugar.tutorius.gtkutils import register_signals_numbered + self._handlers = register_signals_numbered(self._activity, self._handle_events) + + def __expander_cb(self, *args): + if self._expander.get_expanded(): + self.__move_expanded() + else: + self.__move_collapsed() + + def __move_expanded(self): + width = 400 + height = 300 + ww = gtk.gdk.screen_width() + wh = gtk.gdk.screen_height() + + self.set_size_request(width, height) + self.move((ww-width)/2, wh-height) + + def __move_collapsed(self): + width = 150 + height = 40 + ww = gtk.gdk.screen_width() + wh = gtk.gdk.screen_height() + + self.set_size_request(width, height) + self.move((ww-width)/2, wh-height) + + def __realize_cb(self, widget): + self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG) + self.window.set_accept_focus(True) + self.__move_expanded() + + def _disconnect_handlers(self): + for widget, handlerid in self._handlers: + widget.handler_disconnect(handlerid) + self._handlers = [] + + def _handle_events(self,*args): + sig, name = args[-1] + text = "\r\n".join( + (["%s event received from %s" % (sig, name)] + + self._textbuffer.get_text(*(self._textbuffer.get_bounds()) + ).split("\r\n"))[:80] + ) + self._textbuffer.set_text(text) + + diff --git a/src/sugar/tutorius/gtkutils.py b/src/sugar/tutorius/gtkutils.py index 7196469..efa6eef 100644 --- a/src/sugar/tutorius/gtkutils.py +++ b/src/sugar/tutorius/gtkutils.py @@ -17,6 +17,7 @@ """ Utility classes and functions that are gtk related """ +import gtk def find_widget(base, target_fqdn): """Find a widget by digging into a parent widget's children tree @@ -39,7 +40,10 @@ def find_widget(base, target_fqdn): path.pop(0) while len(path) > 0: - obj = obj.get_children()[int(path.pop(0))] + try: + obj = obj.get_children()[int(path.pop(0))] + except: + break return obj @@ -104,7 +108,7 @@ def register_signals_numbered(target, handler, prefix="0", max_depth=None): ret+=register_signals_numbered(child, handler, pre, dep) #register events on the target if a widget XXX necessary to check this? if isinstance(target, gtk.Widget): - for sig in Tutorial.EVENTS: + for sig in EVENTS: try: ret.append( \ (target, target.connect(sig, handler, (sig, prefix) ))\ @@ -114,7 +118,7 @@ def register_signals_numbered(target, handler, prefix="0", max_depth=None): return ret -def register_signals(self, target, handler, prefix=None, max_depth=None): +def register_signals(target, handler, prefix=None, max_depth=None): """ Recursive function to register event handlers on an target and it's children. The event handler is called with an extra @@ -122,19 +126,20 @@ def register_signals(self, target, handler, prefix=None, max_depth=None): the FQDN-style name of the target that triggered the event. This function registers all of the events listed in - Tutorial.EVENTS and omits widgets with a name matching - Tutorial.IGNORED_WIDGETS from the name hierarchy. + EVENTS and omits widgets with a name matching + IGNORED_WIDGETS from the name hierarchy. Example arg tuple added: ("focus", "Activity.Toolbox.Bold") Side effects: -Handlers connected on the various targets - -Handler ID's stored in self.handlers @param target the target to recurse on @param handler the handler function to connect @param prefix name prepended to the target name to form a chain @param max_depth maximum recursion depth, None for infinity + + @returns list of (object, handler_id) """ ret = [] #Gtk Containers have a get_children() function @@ -145,16 +150,16 @@ def register_signals(self, target, handler, prefix=None, max_depth=None): #Recurse with a prefix on all children pre = ".".join( \ [p for p in (prefix, target.get_name()) \ - if not (p is None or p in Tutorial.IGNORED_WIDGETS)] \ + if not (p is None or p in IGNORED_WIDGETS)] \ ) ret += register_signals(child, handler, pre, max_depth-1) name = ".".join( \ [p for p in (prefix, target.get_name()) \ - if not (p is None or p in Tutorial.IGNORED_WIDGETS)] \ + if not (p is None or p in IGNORED_WIDGETS)] \ ) #register events on the target if a widget XXX necessary to check this? if isinstance(target, gtk.Widget): - for sig in Tutorial.EVENTS: + for sig in EVENTS: try: ret.append( \ (target, target.connect(sig, handler, (sig, name) )) \ diff --git a/src/sugar/tutorius/overlayer.py b/src/sugar/tutorius/overlayer.py new file mode 100644 index 0000000..c08ed4c --- /dev/null +++ b/src/sugar/tutorius/overlayer.py @@ -0,0 +1,328 @@ +""" +This guy manages drawing of overlayed widgets. The class responsible for drawing +management (Overlayer) and overlayable widgets are defined here. +""" +# Copyright (C) 2009, Tutorius.org +# +# 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 2 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 gobject +import gtk +import cairo +import pangocairo + +# This is the CanvasDrawable protocol. Any widget wishing to be drawn on the +# overlay must implement it. See TextBubble for a sample implementation. +#class CanvasDrawable(object): +# """Defines the CanvasDrawable protocol""" +# no_expose = None +# def draw_with_context(self, context): +# """ +# Draws the cairo widget with the passed cairo context. +# This will be called if the widget is child of an overlayer. +# """ +# pass + +class Overlayer(gtk.Layout): + """ + This guy manages drawing of overlayed widgets. Those can be standard GTK + widgets or special "cairoDrawable" widgets which support the defined + interface (see the put method). + + @param overlayed widget to be overlayed. Will be resized to full size. + """ + def __init__(self, overlayed=None): + gtk.Layout.__init__(self) + + self._overlayed = overlayed + if overlayed: + self.put(overlayed, 0, 0) + + self.__realizer = self.connect("expose-event", self.__init_realized) + self.connect("size-allocate", self.__size_allocate) + self.show() + + self.__render_handle = None + + def put(self, child, x, y): + """ + Adds a child widget to be overlayed. This can be, overlay widgets or + normal GTK widgets (though normal widgets will alwas appear under + cairo widgets due to the rendering chain). + + @param child the child to add + @param x the horizontal coordinate for positionning + @param y the vertical coordinate for positionning + """ + if hasattr(child, "draw_with_context"): + # if the widget has the CanvasDrawable protocol, use it. + child.no_expose = True + gtk.Layout.put(self, child, x, y) + + + def __init_realized(self, widget, event): + """ + Initializer to set once widget is realized. + Since an expose event is signaled only to realized widgets, we set this + callback for the first expose run. It should also be called after + beign reparented to ensure the window used for drawing is set up. + """ + assert hasattr(self.window, "set_composited"), \ + "compositing not supported or widget not realized." + self.disconnect(self.__realizer) + del self.__realizer + + self.parent.set_app_paintable(True) + + # the parent is composited, so we can access gtk's rendered buffer + # and overlay over. If we don't composite, we won't be able to read + # pixels and background will be black. + self.window.set_composited(True) + self.__render_handle = self.parent.connect_after("expose-event", \ + self.__expose_overlay) + + def __expose_overlay(self, widget, event): + """expose event handler to draw the thing.""" + #get our child (in this case, the event box) + child = widget.get_child() + + #create a cairo context to draw to the window + ctx = widget.window.cairo_create() + + #the source data is the (composited) event box + ctx.set_source_pixmap(child.window, + child.allocation.x, + child.allocation.y) + + #draw no more than our expose event intersects our child + region = gtk.gdk.region_rectangle(child.allocation) + rect = gtk.gdk.region_rectangle(event.area) + region.intersect(rect) + ctx.region (region) + ctx.clip() + + ctx.set_operator(cairo.OPERATOR_OVER) + # has to be blended and a 1.0 alpha would not make it blend + ctx.paint_with_alpha(0.99) + + #draw overlay + for drawn_child in self.get_children(): + if hasattr(drawn_child, "draw_with_context"): + drawn_child.draw_with_context(ctx) + + + def __size_allocate(self, widget, allocation): + """ + Set size allocation (actual gtk widget size) and propagate it to + overlayed child + """ + self.allocation = allocation + # One may wonder why using size_request instead of size_allocate; + # Since widget is laid out in a Layout box, the Layout will honor the + # requested size. Using size_allocate could make a nasty nested loop in + # some cases. + self._overlayed.set_size_request(allocation.width, allocation.height) + + +class TextBubble(gtk.Widget): + """ + A CanvasDrawableWidget drawing a round textbox and a tail pointing + to a specified widget. + """ + def __init__(self, text, speaker=None, tailpos=None): + """ + Creates a new cairo rendered text bubble. + + @param text the text to render in the bubble + @param speaker the widget to compute the tail position from + @param tailpos (optional) position relative to the bubble to use as + the tail position, if no speaker + """ + gtk.Widget.__init__(self) + + # FIXME: ensure previous call does not interfere with widget stacking, + # as using a gtk.Layout and stacking widgets may reveal a screwed up + # order with the cairo widget on top. + self.__label = None + self.__text_dimentions = None + + self.label = text + self.speaker = speaker + self.tailpos = tailpos + self.line_width = 5 + + self.__exposer = self.connect("expose-event", self.__on_expose) + + def draw_with_context(self, context): + """ + Draw using the passed cairo context instead of creating a new cairo + context. This eases blending between multiple cairo-rendered + widgets. + """ + context.translate(self.allocation.x, self.allocation.y) + width = self.allocation.width + height = self.allocation.height + xradius = width/2 + yradius = height/2 + width -= self.line_width + height -= self.line_width + + # bubble border + context.move_to(self.line_width, yradius) + context.curve_to(self.line_width, self.line_width, + self.line_width, self.line_width, xradius, self.line_width) + context.curve_to(width, self.line_width, + width, self.line_width, width, yradius) + context.curve_to(width, height, width, height, xradius, height) + context.curve_to(self.line_width, height, + self.line_width, height, self.line_width, yradius) + context.set_line_width(self.line_width) + context.set_source_rgb(0.0, 0.0, 0.0) + context.stroke() + + # TODO fetch speaker coordinates + + # draw bubble tail + if self.tailpos: + context.move_to(xradius-40, yradius) + context.line_to(self.tailpos[0], self.tailpos[1]) + context.line_to(xradius+40, yradius) + context.set_line_width(self.line_width) + context.set_source_rgb(0.0, 0.0, 0.0) + context.stroke_preserve() + context.set_source_rgb(1.0, 1.0, 0.0) + context.fill() + + # bubble painting. Redrawing the inside after the tail will combine + # both shapes. + # TODO: we could probably generate the shape at initialization to + # lighten computations. + context.move_to(self.line_width, yradius) + context.curve_to(self.line_width, self.line_width, + self.line_width, self.line_width, xradius, self.line_width) + context.curve_to(width, self.line_width, + width, self.line_width, width, yradius) + context.curve_to(width, height, width, height, xradius, height) + context.curve_to(self.line_width, height, + self.line_width, height, self.line_width, yradius) + context.set_source_rgb(1.0, 1.0, 0.0) + context.fill() + + # text + # FIXME create text layout when setting text or in realize method + context.set_source_rgb(0.0, 0.0, 0.0) + pangoctx = pangocairo.CairoContext(context) + text_layout = pangoctx.create_layout() + text_layout.set_text(self.__label) + pangoctx.move_to( + int((self.allocation.width-self.__text_dimentions[0])/2), + int((self.allocation.height-self.__text_dimentions[1])/2)) + pangoctx.show_layout(text_layout) + + # work done. Be kind to next cairo widgets and reset matrix. + context.identity_matrix() + + def do_realize(self): + """ Setup gdk window creation. """ + self.set_flags(gtk.REALIZED | gtk.NO_WINDOW) + # TODO: cleanup window creation code as lot here probably isn't + # necessary. + # See http://www.learningpython.com/2006/07/25/writing-a-custom-widget-using-pygtk/ + # as the following was taken there. + self.window = self.get_parent_window() + if not isinstance(self.parent, Overlayer): + self.unset_flags(gtk.NO_WINDOW) + self.window = gtk.gdk.Window( + self.get_parent_window(), + width=self.allocation.width, + height=self.allocation.height, + window_type=gtk.gdk.WINDOW_CHILD, + wclass=gtk.gdk.INPUT_OUTPUT, + event_mask=self.get_events()|gtk.gdk.EXPOSURE_MASK) + + # Associate the gdk.Window with ourselves, Gtk+ needs a reference + # between the widget and the gdk window + self.window.set_user_data(self) + + # Attach the style to the gdk.Window, a style contains colors and + # GC contextes used for drawing + self.style.attach(self.window) + + # The default color of the background should be what + # the style (theme engine) tells us. + self.style.set_background(self.window, gtk.STATE_NORMAL) + self.window.move_resize(*self.allocation) + + def __on_expose(self, widget, event): + """Redraw event callback.""" + ctx = self.window.cairo_create() + + self.draw_with_context(ctx) + + return True + + def _set_label(self, value): + """Sets the label and flags the widget to be redrawn.""" + self.__label = value + # FIXME hack to calculate size. necessary because may not have been + # realized. We create a fake surface to use builtin math. This should + # probably be done at realization and/or on text setter. + surf = cairo.SVGSurface("/dev/null", 0, 0) + ctx = cairo.Context(surf) + pangoctx = pangocairo.CairoContext(ctx) + text_layout = pangoctx.create_layout() + text_layout.set_text(value) + self.__text_dimentions = text_layout.get_pixel_size() + del text_layout, pangoctx, ctx, surf + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the widget.""" + width, height = self.__text_dimentions + + # FIXME bogus values follows. will need to replace them with + # padding relative to font size and line border size + requisition.width = int(width+30) + requisition.height = int(height+40) + + def do_size_allocate(self, allocation): + """Save zone allocated to the widget.""" + self.allocation = allocation + + def _get_label(self): + """Getter method for the label property""" + return self.__label + + def _set_no_expose(self, value): + """setter for no_expose property""" + if self.__exposer and value: + self.disconnect(self.__exposer) + self.__exposer = None + elif (not self.__exposer) and (not value): + self.__exposer = self.connect("expose-event", self.__on_expose) + + def _get_no_expose(self): + """getter for no_expose property""" + return not self.__exposer + + no_expose = property(fset=_set_no_expose, fget=_get_no_expose, + doc="Whether the widget should handle exposition events or not.") + + label = property(fget=_get_label, fset=_set_label, + doc="Text label which is to be painted on the top of the widget") + +gobject.type_register(TextBubble) + + +# vim:set ts=4 sts=4 sw=4 et: diff --git a/src/sugar/tutorius/services.py b/src/sugar/tutorius/services.py new file mode 100644 index 0000000..467eca0 --- /dev/null +++ b/src/sugar/tutorius/services.py @@ -0,0 +1,68 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Vincent Vinet +# +# 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 2 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 +""" +Services + +This module supplies services to be used by States, FSMs, Actions and Filters. + +Services provided are: +-Access to the running activity +-Access to the running tutorial +""" + + +class ObjectStore(object): + #Begin Singleton code + instance=None + def __new__(cls): + if not ObjectStore.instance: + ObjectStore.instance = ObjectStore.__ObjectStore() + + return ObjectStore.instance + + #End Singleton code + class __ObjectStore(object): + """ + The Object Store is a singleton class that allows access to + the current runnign activity and tutorial. + """ + def __init__(self): + self._activity = None + self._tutorial = None + #self._fsm_path = [] + + def set_activity(self, activity): + """Setter for activity""" + self._activity = activity + + def get_activity(self): + """Getter for activity""" + return self._activity + + activity = property(fset=set_activity,fget=get_activity,doc="activity") + + def set_tutorial(self, tutorial): + """Setter for tutorial""" + self._tutorial = tutorial + + def get_tutorial(self): + """Getter for tutorial""" + return self._tutorial + + tutorial = property(fset=set_tutorial,fget=get_tutorial,doc="tutorial") + + __doc__ = __ObjectStore.__doc__ diff --git a/src/sugar/tutorius/tests/coretests.py b/src/sugar/tutorius/tests/coretests.py new file mode 100644 index 0000000..7792930 --- /dev/null +++ b/src/sugar/tutorius/tests/coretests.py @@ -0,0 +1,212 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Michael Janelle-Montcalm +# +# 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 2 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 +""" +Core Tests + +This module contains all the tests that pertain to the usage of the Tutorius +Core. This means that the Event Filters, the Finite State Machine and all the +related elements and interfaces are tested here. + +""" + +import unittest + +import logging +from sugar.tutorius.actions import Action, OnceWrapper +from sugar.tutorius.core import * +from sugar.tutorius.filters import * + +# Helper classes to help testing +class SimpleTutorial(Tutorial): + """ + Fake tutorial + """ + def __init__(self, start_name="INIT"): + #Tutorial.__init__(self, "Simple Tutorial", None) + self.current_state_name = start_name + self.activity = "TODO : This should be an activity" + + def set_state(self, name): + self.current_state_name = name + +class TrueWhileActiveAction(Action): + """ + This action's active member is set to True after a do and to False after + an undo. + + Used to verify that a State correctly triggers the do and undo actions. + """ + def __init__(self): + self.active = False + + def do(self): + self.active = True + + def undo(self): + self.active = False + + +class CountAction(Action): + """ + This action counts how many times it's do and undo methods get called + """ + def __init__(self): + self.do_count = 0 + self.undo_count = 0 + + def do(self): + self.do_count += 1 + + def undo(self): + self.undo_count += 1 + +class TriggerEventFilter(EventFilter): + """ + This event filter can be triggered by simply calling its execute function. + + Used to fake events and see the effect on the FSM. + """ + def __init__(self, next_state): + EventFilter.__init__(self, next_state) + self.toggle_on_callback = False + + def install_handlers(self, callback, **kwargs): + """ + Forsakes the incoming callback function and just set the inner one. + """ + self._callback = self._inner_cb + + def _inner_cb(self, event_filter): + self.toggle_on_callback = not self.toggle_on_callback + +class BaseActionTests(unittest.TestCase): + def test_do_unimplemented(self): + act = Action() + try: + act.do() + assert False, "do() should trigger a NotImplemented" + except NotImplementedError: + assert True, "do() should trigger a NotImplemented" + + def test_undo(self): + act = Action() + act.undo() + assert True, "undo() should never fail on the base action" + + +class OnceWrapperTests(unittest.TestCase): + def test_onceaction_toggle(self): + """ + Validate that the OnceWrapper wrapper works properly using the + CountAction + """ + act = CountAction() + wrap = OnceWrapper(act) + + assert act.do_count == 0, "do() should not have been called in __init__()" + assert act.undo_count == 0, "undo() should not have been called in __init__()" + + wrap.undo() + + assert act.undo_count == 0, "undo() should not be called if do() has not been called" + + wrap.do() + assert act.do_count == 1, "do() should have been called once" + + wrap.do() + assert act.do_count == 1, "do() should have been called only once" + + wrap.undo() + assert act.undo_count == 1, "undo() should have been called once" + + wrap.undo() + assert act.undo_count == 1, "undo() should have been called only once" + + +# State testing class +class StateTest(unittest.TestCase): + """ + This class has to test the State interface as well as the expected + functionality. + """ + + def test_action_toggle(self): + """ + Validate that the actions are properly done on setup and undone on + teardown. + + Pretty awesome. + """ + act = TrueWhileActiveAction() + + state = State("action_test", action_list=[act]) + + assert act.active == False, "Action is not initialized properly" + + state.setup() + + assert act.active == True, "Action was not triggered properly" + + state.teardown() + + assert act.active == False, "Action was not undone properly" + + def test_event_filter(self): + """ + Tests the fact that the event filters are correctly installed on setup + and uninstalled on teardown. + """ + event_filter = TriggerEventFilter("second_state") + + state = State("event_test", event_filter_list=[event_filter]) + state.set_tutorial(SimpleTutorial()) + + assert event_filter.toggle_on_callback == False, "Wrong init of event_filter" + assert event_filter._callback == None, "Event filter has a registered callback before installing handlers" + + state.setup() + + assert event_filter._callback != None, "Event filter did not register callback!" + + # 'Trigger' the event - This is more like a EventFilter test. + event_filter.do_callback() + + assert event_filter.toggle_on_callback == True, "Event filter did not execute callback" + + state.teardown() + + assert event_filter._callback == None, "Event filter did not remove callback properly" + + def test_warning_set_tutorial_twice(self): + """ + Calls set_tutorial twice and expects a warning on the second. + """ + state = State("start_state") + tut = SimpleTutorial("First") + tut2 = SimpleTutorial("Second") + + state.set_tutorial(tut) + + try: + state.set_tutorial(tut2) + assert False, "No RuntimeWarning was raised on second set_tutorial" + except : + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/src/sugar/tutorius/tests/overlaytests.py b/src/sugar/tutorius/tests/overlaytests.py new file mode 100644 index 0000000..b5fd209 --- /dev/null +++ b/src/sugar/tutorius/tests/overlaytests.py @@ -0,0 +1,115 @@ +# 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 2 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 +""" +GUI Tests + +This module contains all the tests that pertain to the usage of the Tutorius +overlay mechanism used to display objects on top of the application. +""" + +import unittest + +import logging +import gtk, gobject +from sugar.tutorius.actions import Action, BubbleMessage +import sugar.tutorius.overlayer as overlayer + +class CanvasDrawable(object): + def __init__(self): + self._no_expose = False + self.exposition_count = 0 + def _set_no_expose(self, value): + self._no_expose = value + def draw_with_context(self, context): + self.exposition_count += 1 + no_expose = property(fset=_set_no_expose) + + +class OverlayerTest(unittest.TestCase): + def test_cairodrawable_iface(self): + """ + Quickly validates that all our cairo widgets have a minimal interface + implemented. + """ + drawables = [overlayer.TextBubble] + for widget in drawables: + for attr in filter(lambda s:s[0]!='_', dir(CanvasDrawable)): + assert hasattr(widget, attr), \ + "%s not implementing CanvasDrawable iface"%widget.__name__ + + + def test_drawn(self): + """ + Ensures a cairo widget draw method is called at least once in + a real gui app. + """ + win = gtk.Window(type=gtk.WINDOW_TOPLEVEL) + + btn = gtk.Button() + btn.show() + overlay = overlayer.Overlayer(btn) + win.add(overlay) + # let's also try to draw substitute button label + lbl = overlayer.TextBubble("test!") + assert lbl.label == 'test!', \ + "label property mismatch" + btn.show() + lbl.show() + btn.add(lbl) + + lbl.no_expose = True + assert lbl.no_expose, "wrong no_expose evaluation" + lbl.no_expose = False + assert not lbl.no_expose, "wrong no_expose evaluation" + + + widget = overlayer.TextBubble("testing msg!", tailpos=(10,-20)) + widget.exposition_count = 0 + # override draw method + def counter(ctx, self=widget): + self.exposition_count += 1 + self.real_exposer(ctx) + widget.real_exposer = widget.draw_with_context + widget.draw_with_context = counter + # centering allows to test the blending with the label + overlay.put(widget, 50, 50) + widget.show() + assert widget.no_expose, \ + "Overlay should overide exposition handling of widget" + assert not lbl.no_expose, \ + "Non-overlayed cairo should expose as usual" + + # force widget realization + # the child is flagged to be redrawn, the overlay should redraw too. + win.set_default_size(100, 100) + win.show() + + while gtk.events_pending(): + gtk.main_iteration(block=False) + # visual validation: there should be 2 visible bubbles, one as label, + # one as overlay + import time + time.sleep(1) + # as x11 events are asynchronous, wait a bit before assuming it went + # wrong. + while gtk.events_pending(): + gtk.main_iteration(block=False) + assert widget.exposition_count>0, "overlay widget should expose" + + +if __name__ == "__main__": + unittest.main() diff --git a/src/sugar/tutorius/tests/run-tests.py b/src/sugar/tutorius/tests/run-tests.py new file mode 100755 index 0000000..db10c54 --- /dev/null +++ b/src/sugar/tutorius/tests/run-tests.py @@ -0,0 +1,44 @@ +#!/usr/bin/python +# This is a dumb script to run tests on the sugar-jhbuild installed files +# The path added is the default path for the jhbuild build + +INSTALL_PATH="../../../../../../install/lib/python2.5/site-packages/" + +import os, sys +sys.path.insert(0, + os.path.abspath(INSTALL_PATH) +) + +FULL_PATH = os.path.join(INSTALL_PATH,"sugar/tutorius") +GLOB_PATH = os.path.join(FULL_PATH,"*.py") +import unittest +from glob import glob + +import sys +if __name__=='__main__': + if "--coverage" in sys.argv: + sys.argv=[arg for arg in sys.argv if arg != "--coverage"] + import coverage + coverage.erase() + #coverage.exclude('raise NotImplementedError') + coverage.start() + + import coretests + import servicestests + + + suite = unittest.TestSuite() + suite.addTests(unittest.findTestCases(coretests)) + suite.addTests(unittest.findTestCases(servicestests)) + + runner = unittest.TextTestRunner() + runner.run(suite) + + coverage.stop() + coverage.report(glob(GLOB_PATH)) + coverage.erase() + else: + from coretests import * + from servicestests import * + + unittest.main() -- cgit v0.9.1