From cd4b5c8d01e68f170c783b638ad24992dbba8aea Mon Sep 17 00:00:00 2001 From: Vincent Vinet Date: Wed, 18 Mar 2009 18:34:17 +0000 Subject: Merge branch 'master' of ssh://bobthebuilder.mine.nu:8080/home/git --- diff --git a/src/sugar/activity/activity.py b/src/sugar/activity/activity.py index c5dca45..0ad1d91 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) @@ -508,6 +509,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 43cb622..6fd32c7 100644 --- a/src/sugar/tutorius/Makefile.am +++ b/src/sugar/tutorius/Makefile.am @@ -6,4 +6,5 @@ sugar_PYTHON = \ actions.py \ gtkutils.py \ filters.py \ - services.py + services.py \ + overlayer.py diff --git a/src/sugar/tutorius/actions.py b/src/sugar/tutorius/actions.py index 34802c3..da8219e 100644 --- a/src/sugar/tutorius/actions.py +++ b/src/sugar/tutorius/actions.py @@ -17,7 +17,10 @@ This module defines Actions that can be done and undone on a state """ +from sugar.tutorius import gtkutils from dialog import TutoriusDialog +from sugar.tutorius.services import ObjectStore +import overlayer class Action(object): @@ -25,7 +28,7 @@ class Action(object): def __init__(self): object.__init__(self) - def do(self): + def do(self, **kwargs): """ Perform the action """ @@ -58,7 +61,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 @@ -97,5 +100,53 @@ 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 diff --git a/src/sugar/tutorius/gtkutils.py b/src/sugar/tutorius/gtkutils.py index 6841299..efa6eef 100644 --- a/src/sugar/tutorius/gtkutils.py +++ b/src/sugar/tutorius/gtkutils.py @@ -40,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 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/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() -- cgit v0.9.1