diff options
Diffstat (limited to 'src/tutorius/overlayer.py')
-rw-r--r-- | src/tutorius/overlayer.py | 707 |
1 files changed, 707 insertions, 0 deletions
diff --git a/src/tutorius/overlayer.py b/src/tutorius/overlayer.py new file mode 100644 index 0000000..f0d251e --- /dev/null +++ b/src/tutorius/overlayer.py @@ -0,0 +1,707 @@ +""" +This module manages drawing of overlayed widgets. The class responsible for +drawing management (Overlayer) and basic 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 pygtk +pygtk.require('2.0') + +import gobject +import gtk +import cairo +import pangocairo +from math import pi + +from sugar import profile + +# for easy profile access from cairo +color = profile.get_color().get_stroke_color() +xo_line_color = (int(color[1:3], 16)/255.0, + int(color[3:5], 16)/255.0, + int(color[5:7], 16)/255.0) +color = profile.get_color().get_fill_color() +xo_fill_color = (int(color[1:3], 16)/255.0, + int(color[3:5], 16)/255.0, + int(color[5:7], 16)/255.0) +del color + +# 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. + """ + + _instance = None + def __new__(klass, *args): + """ + As the overlay runs in a separate window, bound to the activity's main + window, it doesn't make sense to have multiple instances. + For this reason, __new__ will enforce the singleton pattern and + return the same instance. + + @param klass: the type to instanciate + @returns: the instance of Overlayer for this process. + """ + return Overlayer._instance or gtk.Layout.__new__(klass) + + def __init__(self, overlayed=None): + if Overlayer._instance: + return + Overlayer._instance = self + super(Overlayer, self).__init__() + + _set_wm_compositing(True) + + self._overlayed = overlayed + self._win = gtk.Window(gtk.WINDOW_POPUP) + self._win.set_property('skip-taskbar-hint', True) + self._win.set_property('skip-pager-hint', True) + self._win.set_accept_focus(False) + self._win.set_decorated(False) + self._win.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_NOTIFICATION) + self._win.set_keep_above(True) + self._win.set_app_paintable(True) + + self._win.connect("screen-changed", self.__init_realized) + self._win.connect("expose-event", self.__expose_overlay) + self.__visible = True + gobject.timeout_add_seconds(5, self.__keep_visible) + + # ensure colormap is set before realization, so the window gets + # realized with transparency enabled. + self.__init_realized(self._win) + self._win.show_all() + + self.__render_handle = None + + def __keep_visible(self, *args): + """ + This is a hack to ensure the overlay window is always visible. + Ideally, it would be bound to the window, manager raising itself + with its parent window. Right now, it still is in the same process + as the activity, with the side-effect of disappearing along with any + popup that pops down, including menus and tooltips. + + set self.__visible to false to stop this behaviour. + """ + # TODO: use Activity WM tracking to ensure overlayers don't "fight" + # for the focus. We should also evaluate the benefit of completely + # extracting the overlayer from the activities and putting it as part of + # the tutorius service + # standalone service. + if self.__visible: + self._win.present() + gobject.timeout_add_seconds(5, self.__keep_visible) + + + + def put(self, child, x, y, draggable=False, position_cb=None): + """ + 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 + @param draggable: whether or not this widget's should have the + mouse-drag functionality enabled. + @param position_cb: a callback for obtaining position changes + if draggable was activated. Callbacks signature should be + cb(x, y). + @returns: a handle, used when calling Overlayer.remove(handle) + """ + if hasattr(child, "draw_with_context"): + # if the widget has the CanvasDrawable protocol, use it. + child.no_expose = True + + wrapper = DragWrapper(child, draggable, position_cb) + super(Overlayer, self).put(wrapper.container, x, y) + self.show_all() + + # be sure to redraw or the overlay may not show + self.queue_draw() + + return wrapper + + def remove(self, handle): + """ + Remove the widget with handle from the overlay. + + @param handle: the handle of the widget, as returned by put() + """ + super(Overlayer, self).remove(handle.container) + + def __init_realized(self, widget): + """ + 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. + """ + screen = widget.get_screen() + colormap = screen.get_rgba_colormap() + if not colormap: + colormap = screen.get_rgb_colormap() + + widget.set_colormap(colormap) + + self._win.set_size_request(screen.get_width(), screen.get_height()) + + return True + + def __expose_overlay(self, widget, event): + """expose event handler to draw the thing.""" + screen = widget.get_screen() + colormap = screen.get_rgba_colormap() + widget.set_colormap(colormap) + width, height = widget.get_size() + + ctx = widget.window.cairo_create() + # clear the window + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.0) + ctx.set_operator(cairo.OPERATOR_SOURCE) + ctx.paint() + + ctx.set_source_rgba(1.0, 0.2, 0.2, 0.7) + ctx.rectangle(10.0, 10.0, 200.0, 150.0) + ctx.fill() + ctx.move_to(20,20) + ctx.set_source_rgba(0.0, 0.2, 0.2, 0.7) + layout = ctx.create_layout() + layout.set_markup(self._overlayed.get_title()) + ctx.show_layout(layout) + + + ##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) + + #draw overlay + for child in self.get_children(): + drawn_child = child.get_children()[0] + if hasattr(drawn_child, "draw_with_context"): + drawn_child.draw_with_context(ctx) + + # let events pass through + mask = gtk.gdk.Pixmap(None, width, height, 1) + mask_ctx = mask.cairo_create() + mask_ctx.set_operator(cairo.OPERATOR_CLEAR) + mask_ctx.paint() + self._win.input_shape_combine_mask(mask, 0, 0) + + return False + +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=(0,0)): + """ + 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.label = text + self.speaker = speaker + self.tailpos = tailpos + self.line_width = 5 + self.padding = 20 + + self._no_expose = False + self.__exposer = None + self._text_layout = None + + 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 + # + # TODO fetch speaker coordinates + + # draw bubble tail if present + if self.tailpos != (0,0): + context.move_to(xradius-width/4, yradius) + context.line_to(self.tailpos[0], self.tailpos[1]) + context.line_to(xradius+width/4, yradius) + context.set_line_width(self.line_width) + context.set_source_rgb(*xo_line_color) + context.stroke_preserve() + + # bubble border + context.move_to(width-self.padding, 0.0) + context.line_to(self.padding, 0.0) + context.arc_negative(self.padding, self.padding, self.padding, + 3*pi/2, pi) + context.line_to(0.0, height-self.padding) + context.arc_negative(self.padding, height-self.padding, self.padding, + pi, pi/2) + context.line_to(width-self.padding, height) + context.arc_negative(width-self.padding, height-self.padding, + self.padding, pi/2, 0) + context.line_to(width, self.padding) + context.arc_negative(width-self.padding, self.padding, self.padding, + 0.0, -pi/2) + context.set_line_width(self.line_width) + context.set_source_rgb(*xo_line_color) + context.stroke_preserve() + context.set_source_rgb(*xo_fill_color) + context.fill() + + # bubble painting. Redrawing the inside after the tail will combine + if self.tailpos != (0,0): + context.move_to(xradius-width/4, yradius) + context.line_to(self.tailpos[0], self.tailpos[1]) + context.line_to(xradius+width/4, yradius) + context.set_line_width(self.line_width) + context.set_source_rgb(*xo_fill_color) + context.fill() + + context.set_source_rgb(1.0, 1.0, 1.0) + pangoctx = pangocairo.CairoContext(context) + self._text_layout.set_markup(self.__label) + text_size = self._text_layout.get_pixel_size() + pangoctx.move_to( + int((self.allocation.width-text_size[0])/2), + int((self.allocation.height-text_size[1])/2)) + pangoctx.show_layout(self._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) + self.window = self.get_parent_window() + if not self._no_expose: + self.__exposer = self.connect_after("expose-event", \ + self.__on_expose) + + 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 = "<b>%s</b>"%value + if not self.parent: + return + ctx = self.parent.window.cairo_create() + pangoctx = pangocairo.CairoContext(ctx) + self._text_layout = pangoctx.create_layout() + self._text_layout.set_markup(value) + del pangoctx, ctx#, surf + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the widget.""" + ctx = self.parent.window.cairo_create() + pangoctx = pangocairo.CairoContext(ctx) + self._text_layout = pangoctx.create_layout() + self._text_layout.set_markup(self.__label) + + width, height = self._text_layout.get_pixel_size() + requisition.width = int(width+2*self.padding) + requisition.height = int(height+2*self.padding) + + 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[3:-4] + + def _set_no_expose(self, value): + """setter for no_expose property""" + self._no_expose = value + if not (self.flags() and gtk.REALIZED): + return + + if self.__exposer and value: + self.parent.disconnect(self.__exposer) + self.__exposer = None + elif (not self.__exposer) and (not value): + self.__exposer = self.parent.connect_after("expose-event", + self.__on_expose) + + def _get_no_expose(self): + """getter for no_expose property""" + return self._no_expose + + 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) + +class Rectangle(gtk.Widget): + """ + A CanvasDrawableWidget drawing a rectangle over a specified widget. + """ + def __init__(self, widget, color): + """ + Creates a new Rectangle + + @param widget the widget to cover + @param color the color of the rectangle, as a 4-tuple RGBA + """ + gtk.Widget.__init__(self) + + self.covered = widget + self.color = color + + 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. + """ + if self.covered is None: + # nothing to hide, no coordinates, no drawing + return + mask_alloc = self.covered.get_allocation() + x, y = self.covered.translate_coordinates(self.parent, 0, 0) + + context.rectangle(x, y, mask_alloc.width, mask_alloc.height) + context.set_source_rgba(*self.color) + context.fill() + + def do_realize(self): + """ Setup gdk window creation. """ + self.set_flags(gtk.REALIZED | gtk.NO_WINDOW) + + self.window = self.get_parent_window() + if not isinstance(self.parent, Overlayer): + assert False, "%s should not realize" % type(self).__name__ + print "Danger, Will Robinson! Rectangle parent is not Overlayer" + + def __on_expose(self, widget, event): + """Redraw event callback.""" + assert False, "%s wasn't meant to be exposed by gtk" % \ + type(self).__name__ + ctx = self.window.cairo_create() + + self.draw_with_context(ctx) + + return True + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the masked widget.""" + # This is a bit pointless, as this will always ignore allocation and + # be rendered directly on overlay, but for sanity, let's put some values + # in there. + if not self.covered: + requisition.width = 0 + requisition.height = 0 + return + + masked_alloc = self.covered.get_allocation() + requisition.width = masked_alloc.width + requisition.height = masked_alloc.height + + def do_size_allocate(self, allocation): + """Save zone allocated to the widget.""" + self.allocation = allocation + + 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.") +gobject.type_register(Rectangle) + +class Mask(gtk.EventBox): + """ + A CanvasDrawableWidget drawing a rectangle over a specified widget. + """ + def __init__(self, catch_events=False, pass_thru=()): + """ + Creates a new Rectangle + + @param catch_events whether the Mask should catch events + @param pass_thru the widgets that "punch holes" through this Mask. + Events will pass through to those widgets. + """ + gtk.EventBox.__init__(self) + self.no_expose = True # ignored + self._catch_events = False + self.catch_events = catch_events + self.pass_thru = list(pass_thru) + + def __del__(self): + for widget in self.pass_thru: + widget.drag_unhighlight() + + def mask(self, widget): + """ + Remove the widget from the unmasked list. + @param widget a widget to remask + """ + assert widget in self.pass_thru, \ + "trying to mask already masked widget" + self.pass_thru.remove(widget) + widget.drag_unhighlight() + + def unmask(self, widget): + """ + Add to the unmasked list the widget passed. + A hole will be punched through the mask at that widget's position. + @param widget a widget to unmask + """ + if widget not in self.pass_thru: + widget.drag_highlight() + self.pass_thru.append(widget) + + + def set_catch_events(self, do_catch): + """Sets whether the mask catches events of widgets under it""" + if bool(self._catch_events) ^ bool(do_catch): + if do_catch: + self._catch_events = True + self.grab_add() + else: + self.grab_remove() + self._catch_events = False + + def get_catch_events(self): + """Gets whether the mask catches events of widgets under it""" + return bool(self._catch_handle) + + catch_events = property(fset=set_catch_events, fget=get_catch_events) + + 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. + """ + # Fill parent container + mask_alloc = self.parent.get_allocation() + oldrule = context.get_fill_rule() + context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) + x, y = self.translate_coordinates(self.parent, 0, 0) + + context.rectangle(x, y, mask_alloc.width, mask_alloc.height) + for hole in self.pass_thru: + alloc = hole.get_allocation() + x, y = hole.translate_coordinates(self.parent, 0, 0) + context.rectangle(x, y, alloc.width, alloc.height) + context.set_source_rgba(0, 0, 0, 0.7) + context.fill() + context.set_fill_rule(oldrule) + + def do_size_request(self, requisition): + """Fill requisition with size occupied by the masked widget.""" + # This is required for the event box to span across all the parent. + alloc = self.parent.get_allocation() + requisition.width = alloc.width + requisition.height = alloc.height + + def do_size_allocate(self, allocation): + """Save zone allocated to the widget.""" + self.allocation = allocation + +gobject.type_register(Mask) + +class DragWrapper(object): + """Wrapper to allow gtk widgets to be dragged around""" + def __init__(self, widget, draggable=False, callback=None): + """ + Creates a wrapper to allow gtk widgets to be mouse dragged, if the + parent container supports the move() method, like a gtk.Layout. + + Note: initial position attribute is none, until the widget is moved. + + @param widget the widget to enhance with drag capability + @param draggable wether to enable the drag functionality now + @param callback: a callback for obtaining position changes + if draggable was activated. Callbacks signature should be + cb(x, y). + """ + self._widget = widget + self.container = gtk.EventBox() + self.container.show() + self.container.set_visible_window(False) + self.container.add(widget) + + self._drag_on = False # whether dragging is enabled + self._rel_pos = (0,0) # mouse pos relative to widget + self._handles = [] # event handlers + self._dragging = False # whether a drag is in progress + self.position = None # position of the widget + self._callback = callback + + self.draggable = draggable + + def _pressed_cb(self, widget, evt): + """Callback for start of drag event""" + self.container.grab_add() + self._dragging = True + self._rel_pos = evt.get_coords() + + def _moved_cb(self, widget, evt): + """Callback for mouse drag events""" + if not self._dragging: + return + + # Focus on a widget before dragging another would + # create addititonal move event, making the widget jump unexpectedly. + # Solution found was to process those focus events before dragging. + if gtk.events_pending(): + return + + xrel, yrel = self._rel_pos + xparent, yparent = evt.get_coords() + xparent, yparent = widget.translate_coordinates(widget.parent, + xparent, yparent) + self.position = (xparent-xrel, yparent-yrel) + self._widget.parent.move(self.container, *self.position) + self._widget.parent.queue_draw() + + def _released_cb(self, *args): + """Callback for end of drag (mouse release).""" + self.container.grab_remove() + self._dragging = False + if self._callback: + callback(*self.position) + + def _drag_end(self, *args): + """Callback for end of drag (stolen focus).""" + self._dragging = False + + def set_draggable(self, value): + """Setter for the draggable property""" + if bool(value) ^ bool(self._drag_on): + if value: + size = self._widget.size_request() + self.container.set_size_request(*size) + self._handles.append(self.container.connect( + "button-press-event", self._pressed_cb)) + self._handles.append(self.container.connect( + "button-release-event", self._released_cb)) + self._handles.append(self.container.connect( + "motion-notify-event", self._moved_cb)) + self._handles.append(self.container.connect( + "grab-broken-event", self._drag_end)) + else: + while self._handles: + handle = self._handles.pop() + self.container.disconnect(handle) + self._drag_on = value + + def get_draggable(self): + """Getter for the draggable property""" + return self._drag_on + + draggable = property(fset=set_draggable, fget=get_draggable, \ + doc="Property to enable the draggable behaviour of the widget") + + def set_widget(self, widget): + """Setter for the widget property""" + if self._dragging or self._drag_on: + raise Exception("Can't change widget while dragging is enabled.") + + assert hasattr(widget, "parent"), "wrapped widget should have a parent" + parent = widget.parent + assert hasattr(parent, "move"), "container of widget need move method" + self._widget = widget + + def get_widget(self): + """Getter for the widget property""" + return self._widget + + widget = property(fset=set_widget, fget=get_widget) + + +GCONF_COMPOSITING_KEY = '/apps/metacity/general/compositing_manager' + +def _set_wm_compositing(compositing): + """ + Utility method to reset metacity's compostiting. + Compositing allows windows to blend together and avoids flickers + when widgets redraw (like the frame animation). + + @type compositing: bool + @param compositing: Whether to enable metacity compositing feature. + @returns: the new value for compositing + """ + import gconf + client = gconf.client_get_default() + if not client.get_bool(GCONF_COMPOSITING_KEY): + client.set_bool(GCONF_COMPOSITING_KEY, compositing) + return client.get_bool(GCONF_COMPOSITING_KEY) + + +# vim:set ts=4 sts=4 sw=4 et: |