# -*- coding: utf-8 -*- # Copyright (C) 2011 Canonical # # Authors: # Michael Vogt # # 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; version 3. # # 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 Street, Fifth Floor, Boston, MA 02110-1301 USA import cairo import logging import os from gettext import gettext as _ from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GLib from gi.repository import GObject from gi.repository import GdkPixbuf from gi.repository import WebKit from urlparse import urlparse from softwarecenter.utils import SimpleFileDownloader from softwarecenter.ui.gtk3.em import StockEms from softwarecenter.ui.gtk3.drawing import rounded_rect from softwarecenter.ui.gtk3.utils import point_in import softwarecenter.paths LOG = logging.getLogger(__name__) _asset_cache = {} _HAND = Gdk.Cursor.new(Gdk.CursorType.HAND2) EXHIBIT_HTML = """ """ class FeaturedExhibit(object): def __init__(self): self.id = 0 self.package_names = ("armagetronad,calibre,cheese,homebank," "stellarium,gimp,inkscape,blender,audacity,gufw,frozen-bubble," "fretsonfire,moovida,liferea,arista,gtg,freeciv-client-gtk," "openshot,supertuxkart,tumiki-fighters,tuxpaint," "webservice-office-zoho") self.title_translated = _("Our star apps") self.published = True self.banner_urls = ["file:%s" % (os.path.abspath(os.path.join( softwarecenter.paths.datadir, "default_banner/fallback.png")))] self.html = EXHIBIT_HTML % { 'banner_url': self.banner_urls[0], 'title': _("Our star apps"), 'subtitle': _("Come and explore our favourites"), } self.click_url = "" # we should extract this automatically from the html #self.atk_name = _("Default Banner") #self.atk_description = _("You see this banner because you have no " # "cached banners") class _HtmlRenderer(Gtk.OffscreenWindow): __gsignals__ = { "render-finished": (GObject.SignalFlags.RUN_LAST, None, (), ) } def __init__(self): Gtk.OffscreenWindow.__init__(self) self.view = WebKit.WebView() settings = self.view.get_settings() settings.set_property("enable-java-applet", False) settings.set_property("enable-plugins", False) settings.set_property("enable-scripts", False) self.view.set_size_request(-1, ExhibitBanner.MAX_HEIGHT) self.add(self.view) self.show_all() self.loader = SimpleFileDownloader() self.loader.connect("file-download-complete", self._on_one_download_complete) self.loader.connect("error", self._on_download_error) self.exhibit = None self._downloaded_banner_images = [] self.view.connect( "notify::load-status", self._on_internal_renderer_load_status) def set_exhibit(self, exhibit): LOG.debug("set_exhibit: '%s'" % exhibit) self._downloaded_banner_images = [] self.exhibit = exhibit self._download_next_banner_image() def _on_download_error(self, loader, exception, error): LOG.warn("download failed: '%s', '%s'" % (exception, error)) def _on_one_download_complete(self, loader, path): LOG.debug("downloading of '%s' finished" % path) self._downloaded_banner_images.append(path) if len(self._downloaded_banner_images) < len(self.exhibit.banner_urls): self._download_next_banner_image() self._on_all_banner_images_downloaded() def _on_all_banner_images_downloaded(self): LOG.debug("downloading of all banner images finished") html = self.exhibit.html cache_dir = os.path.join( softwarecenter.paths.SOFTWARE_CENTER_CACHE_DIR, "download-cache") for url, local_file in zip(self.exhibit.banner_urls, self._downloaded_banner_images): # no need to mangle local urls if url.startswith("file"): continue scheme, netloc, server_path, para, query, frag = urlparse(url) image_name = os.path.basename(local_file) # replace the server side path with the local image name, this # assumes that the image always comes from the same server as # the html html = html.replace(server_path, image_name) self.exhibit.html = html LOG.debug("mangled html: '%s'" % html) self.view.load_string(html, "text/html", "UTF-8", "file:%s/" % cache_dir) def _download_next_banner_image(self): LOG.debug("_download_next_banner_image") self.loader.download_file( self.exhibit.banner_urls[len(self._downloaded_banner_images)], use_cache=True, simple_quoting_for_webkit=True) def _on_internal_renderer_load_status(self, view, prop): """Called when the rendering of the html banner is done""" if view.get_property("load-status") == WebKit.LoadStatus.FINISHED: # this needs to run with a timeout because otherwise the # status is emitted before the offscreen image is finished GLib.timeout_add(100, lambda: self.emit("render-finished")) class ExhibitButton(Gtk.Button): def __init__(self): Gtk.Button.__init__(self) self.DROPSHADOW = GdkPixbuf.Pixbuf.new_from_file( os.path.join(softwarecenter.paths.datadir, "ui/gtk3/art/circle-dropshadow.png")) self.set_focus_on_click(False) self.set_name("exhibit-button") self._dropshadow = None # is the current active "page" self.is_active = False self.connect("size-allocate", self.on_size_allocate) def on_size_allocate(self, *args): a = self.get_allocation() if (self._dropshadow is not None and a.width == self._dropshadow.get_width() and a.height == self._dropshadow.get_height()): return self._dropshadow = self.DROPSHADOW.scale_simple( a.width, a.width, GdkPixbuf.InterpType.BILINEAR) self._margin = int(float(a.width) / self.DROPSHADOW.get_width() * 15) def do_draw(self, cr): cr.save() a = self.get_allocation() #state = self.get_state_flags() context = self.get_style_context() ds_h = self._dropshadow.get_height() y = (a.height - ds_h) / 2 Gdk.cairo_set_source_pixbuf(cr, self._dropshadow, 0, y) cr.paint() # layout circle x = self._margin y = (a.height - ds_h) / 2 + self._margin w = a.width - 2 * self._margin h = a.width - 2 * self._margin cr.new_path() r = min(w, h) * 0.5 x += int((w - 2 * r) / 2) y += int((h - 2 * r) / 2) from math import pi cr.arc(r + x, r + y, r, 0, 2 * pi) cr.close_path() if self.is_active: color = context.get_background_color(Gtk.StateFlags.SELECTED) else: color = context.get_background_color(Gtk.StateFlags.INSENSITIVE) Gdk.cairo_set_source_rgba(cr, color) cr.fill() cr.restore() for child in self: self.propagate_draw(child, cr) class ExhibitArrowButton(ExhibitButton): def __init__(self, arrow_type, shadow_type=Gtk.ShadowType.IN): ExhibitButton.__init__(self) a = Gtk.Alignment() a.set_padding(1, 1, 1, 1) a.add(Gtk.Arrow.new(arrow_type, shadow_type)) self.add(a) class ExhibitBanner(Gtk.EventBox): # FIXME: sometimes despite set_exhibit being called the new exhibit isn't # actually displayed __gsignals__ = { "show-exhibits-clicked": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,), ) } DROPSHADOW_HEIGHT = 11 MAX_HEIGHT = 200 # pixels TIMEOUT_SECONDS = 10 def __init__(self): Gtk.EventBox.__init__(self) vbox = Gtk.VBox() vbox.set_margin_bottom(StockEms.SMALL) vbox.set_margin_left(StockEms.LARGE) vbox.set_margin_right(StockEms.LARGE) self.add(vbox) # defined to make overriding softwarecenter.paths.datadir possible self.NORTHERN_DROPSHADOW = os.path.join( softwarecenter.paths.datadir, "ui/gtk3/art/exhibit-dropshadow-n.png") self.SOUTHERN_DROPSHADOW = os.path.join( softwarecenter.paths.datadir, "ui/gtk3/art/exhibit-dropshadow-s.png") self.FALLBACK = os.path.join( softwarecenter.paths.datadir, "default_banner/fallback.png") hbox = Gtk.HBox(spacing=StockEms.SMALL) vbox.pack_end(hbox, False, False, 0) next = ExhibitArrowButton(Gtk.ArrowType.RIGHT) previous = ExhibitArrowButton(Gtk.ArrowType.LEFT) self.nextprev_hbox = Gtk.HBox() self.nextprev_hbox.pack_start(previous, False, False, 0) self.nextprev_hbox.pack_start(next, False, False, 0) hbox.pack_end(self.nextprev_hbox, False, False, 0) self.index_hbox = Gtk.HBox(spacing=StockEms.SMALL) alignment = Gtk.Alignment.new(1.0, 1.0, 0.0, 1.0) alignment.add(self.index_hbox) hbox.pack_end(alignment, False, False, 0) self.cursor = 0 self._timeout = 0 self.pressed = False self.alpha = 1.0 self.image = None self.old_image = None self.renderer = _HtmlRenderer() self.renderer.connect("render-finished", self.on_banner_rendered) self.set_visible_window(False) self.set_size_request(-1, self.MAX_HEIGHT) self.exhibits = [] next.connect('clicked', self.on_next_clicked) previous.connect('clicked', self.on_previous_clicked) self._dotsigs = [] self._cache_art_assets() self._init_event_handling() # fill this in later self._toplevel_window = None def _init_event_handling(self): self.set_can_focus(True) self.set_events(Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) self.connect("enter-notify-event", self.on_enter_notify) self.connect("leave-notify-event", self.on_leave_notify) self.connect("button-press-event", self.on_button_press) self.connect("button-release-event", self.on_button_release) self.connect("key-press-event", self.on_key_press) def _init_mouse_pointer(self): window = self.get_window() if not window: return if not self.exhibits[self.cursor].package_names: window.set_cursor(None) else: window.set_cursor(_HAND) def on_enter_notify(self, *args): self._init_mouse_pointer() def on_leave_notify(self, *args): window = self.get_window() window.set_cursor(None) def on_button_release(self, widget, event): if not point_in(self.get_allocation(), int(event.x), int(event.y)): self.pressed = False return exhibit = self.exhibits[self.cursor] if (exhibit.package_names or exhibit.click_url) and self.pressed: self.emit("show-exhibits-clicked", exhibit) self.pressed = False def on_button_press(self, widget, event): if point_in(self.get_allocation(), int(event.x), int(event.y)): self.pressed = True def on_key_press(self, widget, event): # activate if (event.keyval == Gdk.keyval_from_name("space") or event.keyval == Gdk.keyval_from_name("Return") or event.keyval == Gdk.keyval_from_name("KP_Enter")): exhibit = self.exhibits[self.cursor] if exhibit.package_names: self.emit("show-exhibits-clicked", exhibit) return True # previous if (event.keyval == Gdk.keyval_from_name("Left") or event.keyval == Gdk.keyval_from_name("KP_Left")): self.on_previous_clicked() return True # next if (event.keyval == Gdk.keyval_from_name("Right") or event.keyval == Gdk.keyval_from_name("KP_Right")): self.on_next_clicked() return True return False def on_next_clicked(self, *args): self.next_exhibit() self.queue_next() def on_previous_clicked(self, *args): self.previous() self.queue_next() def cleanup_timeout(self): if self._timeout > 0: GLib.source_remove(self._timeout) self._timeout = 0 def _render_exhibit_at_cursor(self): # init the mouse pointer self._init_mouse_pointer() # check the cursor is within range n_exhibits = len(self.exhibits) cursor = self.cursor if n_exhibits == 0 or (cursor < 0 and cursor >= n_exhibits): return # copy old image for the fade if self.image: self.old_image = self.image.copy() # set the right one self.renderer.set_exhibit(self.exhibits[cursor]) # make sure the active button is having a different color for i, w in enumerate(self.index_hbox): w.is_active = (i == cursor) def on_paging_dot_clicked(self, dot, index): self.cursor = index self._render_exhibit_at_cursor() # next() is a special function in py3 so we call this next_exhibit def next_exhibit(self): if len(self.exhibits) - 1 == self.cursor: self.cursor = 0 else: self.cursor += 1 self._render_exhibit_at_cursor() self._timeout = 0 return False def previous(self): if self.cursor == 0: self.cursor = len(self.exhibits) - 1 else: self.cursor -= 1 self._render_exhibit_at_cursor() return False def queue_next(self): self.cleanup_timeout() self._timeout = GLib.timeout_add_seconds( self.TIMEOUT_SECONDS, self.next_exhibit) return self._timeout def on_banner_rendered(self, renderer): self.image = renderer.get_pixbuf() if self.image.get_width() == 1: # the offscreen window is not really as such content not # correctly rendered GLib.timeout_add(500, self.on_banner_rendered, renderer) return from gi.repository import Atk self.get_accessible().set_name( self.exhibits[self.cursor].title_translated) self.get_accessible().set_role(Atk.Role.PUSH_BUTTON) self._fade_in() self.queue_next() return False def _fade_in(self, step=0.05): self.alpha = 0.0 def fade_step(): retval = True self.alpha += step if self.alpha >= 1.0: self.alpha = 1.0 self.old_image = None retval = False self.queue_draw() return retval GLib.timeout_add(50, fade_step) def _cache_art_assets(self): global _asset_cache assets = _asset_cache if assets: return assets #~ surf = cairo.ImageSurface.create_from_png(self.NORTHERN_DROPSHADOW) #~ ptrn = cairo.SurfacePattern(surf) #~ ptrn.set_extend(cairo.EXTEND_REPEAT) #~ assets["n"] = ptrn surf = cairo.ImageSurface.create_from_png(self.SOUTHERN_DROPSHADOW) ptrn = cairo.SurfacePattern(surf) ptrn.set_extend(cairo.EXTEND_REPEAT) assets["s"] = ptrn return assets def do_draw(self, cr): # ensure that we pause the exhibits carousel if the window goes # into the background self._init_pause_handling_if_needed() # hide the next/prev buttons if needed if len(self.exhibits) == 1: self.nextprev_hbox.hide() else: self.nextprev_hbox.show() # do the actual drawing cr.save() a = self.get_allocation() cr.set_source_rgb(1, 1, 1) cr.paint() # workaround a really odd bug in the offscreen window of the # webkit view that leaves a 8px white border around the rendered # image OFFSET = -8 if self.old_image is not None: #x = (a.width - self.old_image.get_width()) / 2 x = OFFSET y = OFFSET Gdk.cairo_set_source_pixbuf(cr, self.old_image, x, y) cr.paint() if self.image is not None: #x = (a.width - self.image.get_width()) / 2 x = OFFSET y = OFFSET Gdk.cairo_set_source_pixbuf(cr, self.image, x, y) cr.paint_with_alpha(self.alpha) if self.has_focus(): # paint a focal border/frame context = self.get_style_context() context.save() context.add_class(Gtk.STYLE_CLASS_HIGHLIGHT) highlight = context.get_background_color(self.get_state_flags()) context.restore() rounded_rect(cr, 1, 1, a.width - 2, a.height - 3, 5) Gdk.cairo_set_source_rgba(cr, highlight) cr.set_line_width(6) cr.stroke() # paint dropshadows last #~ cr.rectangle(0, 0, a.width, self.DROPSHADOW_HEIGHT) #~ cr.clip() #~ cr.set_source(assets["n"]) #~ cr.paint() #~ cr.reset_clip() cr.rectangle(0, a.height - self.DROPSHADOW_HEIGHT, a.width, self.DROPSHADOW_HEIGHT) cr.clip() cr.save() cr.translate(0, a.height - self.DROPSHADOW_HEIGHT) cr.set_source(_asset_cache["s"]) cr.paint() cr.restore() cr.set_line_width(1) cr.move_to(-0.5, a.height - 0.5) cr.rel_line_to(a.width + 1, 0) cr.set_source_rgba(1, 1, 1, 0.75) cr.stroke() cr.restore() for child in self: self.propagate_draw(child, cr) def _init_pause_handling_if_needed(self): # nothing to do if we have the toplevel already if self._toplevel_window: return # find toplevel Gtk.Window w = self while w.get_parent(): w = w.get_parent() # paranoia, should never happen if not isinstance(w, Gtk.Window): return # connect to property changes for the toplevel focus w.connect("notify::has-toplevel-focus", self._on_main_window_is_active_changed) self._toplevel_window = w def _on_main_window_is_active_changed(self, win, gparamspec): """ this tracks focus of the main window and pauses the exhibits cycling if the window does not have the toplevel focus """ is_active = win.get_property("has-toplevel-focus") if is_active: self.queue_next() else: self.cleanup_timeout() def set_exhibits(self, exhibits_list): if not exhibits_list: return self.exhibits = exhibits_list self.cursor = 0 for child in self.index_hbox: child.destroy() for sigid in self._dotsigs: GLib.source_remove(sigid) self._dotsigs = [] if len(self.exhibits) > 1: for i, exhibit in enumerate(self.exhibits): dot = ExhibitButton() dot.set_size_request(StockEms.LARGE, StockEms.LARGE) self._dotsigs.append( dot.connect("clicked", self.on_paging_dot_clicked, len(self.exhibits) - 1 - i) # index ) self.index_hbox.pack_end(dot, False, False, 0) self.index_hbox.show_all() self._render_exhibit_at_cursor()