diff --git a/rigo/data/ui/gtk3/css/rigo.css b/rigo/data/ui/gtk3/css/rigo.css index d9b09be76..14ca7a53e 100644 --- a/rigo/data/ui/gtk3/css/rigo.css +++ b/rigo/data/ui/gtk3/css/rigo.css @@ -116,3 +116,14 @@ GtkInfoBar #message-area-error GtkLabel { #comment-box-author { color: brown; } + +/* Application View Image Box */ +#image-box-title { + color: #613056; /* purple */ +} +#image-box-comment { + color: #345f14; +} +#image-box-author { + color: brown; +} diff --git a/rigo/data/ui/gtk3/rigo.ui b/rigo/data/ui/gtk3/rigo.ui index ab9738ff9..84802172c 100644 --- a/rigo/data/ui/gtk3/rigo.ui +++ b/rigo/data/ui/gtk3/rigo.ui @@ -377,16 +377,7 @@ - - True - False - Want to see more? - - - True - True - 2 - + @@ -409,6 +400,42 @@ 6 + + + True + False + 25 + 25 + + + 300 + True + False + + + + + + + + True + True + 7 + + + + + True + False + Want to see more? + + + True + True + 5 + 8 + + diff --git a/rigo/rigo/models/application.py b/rigo/rigo/models/application.py index 5962ee96b..aa8170ad5 100644 --- a/rigo/rigo/models/application.py +++ b/rigo/rigo/models/application.py @@ -34,7 +34,8 @@ from entropy.const import const_debug_write, const_debug_enabled, \ from entropy.i18n import _ from entropy.misc import ParallelTask from entropy.services.client import WebService -from entropy.client.services.interfaces import ClientWebService +from entropy.client.services.interfaces import ClientWebService, \ + DocumentList import entropy.tools @@ -544,7 +545,7 @@ class ApplicationMetadata(object): offset, callback): """ Asynchronously download updated information regarding the - comments of given package. + comments of the given application. This request disables local cache usage and directly queries the remote Web Service. Once data is available, callback will be called passing the returned @@ -579,6 +580,71 @@ class ApplicationMetadata(object): task.daemon = True task.start() + @staticmethod + def download_images_async(entropy_ws, package_key, repository_id, + offset, callback, ignore_icons=True): + """ + Asynchronously download updated information regarding the images + of the given application. + This request disables local cache usage and directly queries + the remote Web Service. + Once data is available, callback will be called passing the returned + payload as argument. + For this method, the signature of callback is: + callback(DocumentList) + If the Web Service is not available for repository, None is passed as + payload of callback. + Please note that the callback is called from another thread. + """ + webserv = entropy_ws.get(repository_id) + if webserv is None: + task = ParallelTask(callback, None) + task.name = "DownloadImagesAsync::None" + task.daemon = True + task.start() + return None + + def _getter(): + outcome = None + try: + images = webserv.get_images( + [package_key], cache=False, + latest=True, offset=offset)[package_key] + + fetched_images = [] + for image in images: + if image.is_icon() and ignore_icons: + continue + # check if we have the file on-disk, otherwise + # spawn the fetch in parallel. + image_path = image.local_document() + if not os.path.isfile(image_path): + local_path = ApplicationMetadata._download_document( + webserv, image) + if local_path: + fetched_images.append(image) + else: + fetched_images.append(image) + + # final DocumentList may contain less elements + # than those advertised by total(). + _outcome = DocumentList( + images.package_name(), + images.total(), + images.offset()) + _outcome.extend(fetched_images) + outcome = _outcome + + finally: + # ignore exceptions, if any, and always + # call callback. + callback(outcome) + + task = ParallelTask(_getter) + task.name = "DownloadImagesAsync::Getter" + task.daemon = True + task.start() + class SignalBoolean(object): @@ -927,6 +993,31 @@ class Application(object): "Application{%s}.download_comments called" % ( self._pkg_match,)) + def download_images(self, callback, offset=0): + """ + Return Application Images Entropy Document object. + In case of missing comments (locally), None is returned. + The actual outcome of this method is a DocumentList object. + """ + repo = self._entropy.open_repository(self._repo_id) + key_slot = repo.retrieveKeySlot(self._pkg_id) + if key_slot is None: + task = ParallelTask(callback, None) + task.name = "DownloadImagesNoneCallback" + task.daemon = True + task.start() + return + + key, slot = key_slot + ApplicationMetadata.download_images_async( + self._entropy_ws, key, self._repo_id, + offset, callback) + + if const_debug_enabled(): + const_debug_write(__name__, + "Application{%s}.download_images called" % ( + self._pkg_match,)) + def is_webservice_available(self): """ Return whether the Entropy Web Service is available diff --git a/rigo/rigo/ui/gtk3/widgets/comments.py b/rigo/rigo/ui/gtk3/widgets/comments.py index 45b0e802e..1181e42e0 100644 --- a/rigo/rigo/ui/gtk3/widgets/comments.py +++ b/rigo/rigo/ui/gtk3/widgets/comments.py @@ -46,7 +46,7 @@ class CommentBox(Gtk.VBox): self._comment[ts_id]) time_str = escape_markup(time_str) label.set_markup( - "%s" % (self._comment[user_id],) \ + "%s" % (escape_markup(self._comment[user_id]),) \ + ", " + time_str + "" \ + "") label.set_line_wrap(True) @@ -63,32 +63,34 @@ class CommentBox(Gtk.VBox): if title: title_id = Document.DOCUMENT_TITLE_ID label = Gtk.Label() - label.set_markup("" + self._comment[title_id] + "") + label_align = Gtk.Alignment() + label_align.set_padding(0, 3, 0, 0) + label_align.add(label) + label.set_markup( + "" + escape_markup(self._comment[title_id]) + "") label.set_name("comment-box-title") label.set_line_wrap(True) label.set_line_wrap_mode(Pango.WrapMode.WORD) label.set_alignment(0.0, 0.0) label.set_selectable(True) label.show() - vbox.pack_start(label, False, False, 0) + vbox.pack_start(label_align, False, False, 0) data_id = Document.DOCUMENT_DATA_ID label = Gtk.Label() - label.set_markup("" + self._comment[data_id] + "") + label_align = Gtk.Alignment() + label_align.set_padding(0, 15, 0, 0) + label_align.add(label) + label.set_markup( + "" + \ + escape_markup(self._comment[data_id]) + "") label.set_name("comment-box-comment") label.set_line_wrap(True) label.set_line_wrap_mode(Pango.WrapMode.WORD) label.set_alignment(0.0, 0.0) label.set_selectable(True) label.show() - vbox.pack_start(label, False, False, 0) + vbox.pack_start(label_align, False, False, 0) - if self._is_last: - self.pack_start(vbox, False, False, 0) - vbox.show_all() - else: - align = Gtk.Alignment() - align.set_padding(0, 10, 0, 0) - align.add(vbox) - self.pack_start(align, False, False, 0) - align.show_all() + self.pack_start(vbox, False, False, 0) + vbox.show_all() diff --git a/rigo/rigo/ui/gtk3/widgets/images.py b/rigo/rigo/ui/gtk3/widgets/images.py new file mode 100644 index 000000000..55e730735 --- /dev/null +++ b/rigo/rigo/ui/gtk3/widgets/images.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2012 Fabio Erculiani + +Authors: + Fabio Erculiani + +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 os +from threading import Lock + +from gi.repository import Gtk, Gdk, Pango, GObject, GdkPixbuf + +from rigo.utils import escape_markup +from rigo.ui.gtk3.utils import get_sc_icon_theme +from rigo.paths import DATA_DIR +from rigo.enums import Icons +from rigo.utils import open_url + +from entropy.client.services.interfaces import Document, DocumentFactory + +import entropy.tools + + +class ImageBox(Gtk.VBox): + + IMAGE_SIZE = 160 + _MISSING_ICON = None + _MISSING_ICON_MUTEX = Lock() + _ICONS = None + _ICONS_MUTEX = Lock() + _hand = Gdk.Cursor.new(Gdk.CursorType.HAND2) + + def __init__(self, image, is_last=False): + Gtk.VBox.__init__(self) + self._image = image + self.set_name("image-box") + self.set_spacing(2) + self._is_last = is_last + + @property + def _icons(self): + """ + Get Icons Theme Object. + """ + if ImageBox._ICONS is not None: + return ImageBox._ICONS + with ImageBox._ICONS_MUTEX: + if ImageBox._ICONS is not None: + return ImageBox._ICONS + _icons = get_sc_icon_theme(DATA_DIR) + AppListStore._ICONS = _icons + return _icons + + @property + def _missing_icon(self): + """ + Return the missing icon Gtk.Image() if needed. + """ + if ImageBox._MISSING_ICON is not None: + return ImageBox._MISSING_ICON + with ImageBox._MISSING_ICON_MUTEX: + if ImageBox._MISSING_ICON is not None: + return ImageBox._MISSING_ICON + _missing_icon = self._icons.load_icon( + Icons.MISSING_APP, ImageBox.IMAGE_SIZE, 0) + ImageBox._MISSING_ICON = _missing_icon + return _missing_icon + + def _on_image_clicked(self, widget, event): + """ + Image clicked event, load image in browser. + """ + url = self._image.document_url() + if url is not None: + open_url(url) + + def _on_image_enter(self, widget, event): + """ + Cursor over image, switch cursor. + """ + widget.get_window().set_cursor(ImageBox._hand) + + def _on_image_leave(self, widget, event): + """ + Cursor leaving image, switch cursor. + """ + widget.get_window().set_cursor(None) + + def render(self): + + vbox = Gtk.VBox() + hbox = Gtk.HBox() + vbox.pack_start(hbox, False, False, 0) + + use_missing = False + image_path = self._image.local_document() + if not os.path.isfile(image_path): + img_buf = self._missing_icon + use_missing = True + else: + img = Gtk.Image.new_from_file(image_path) + img_buf = img.get_pixbuf() + if img_buf is None: + use_missing = True + img_buf = self._missing_icon + del img + + w, h = img_buf.get_width(), img_buf.get_height() + del img_buf + if w < 1: + # not legit + use_missing = True + img_buf = self._missing_icon + + width = ImageBox.IMAGE_SIZE + height = width * h / w + + if not use_missing: + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + image_path, width, height) + except GObject.GError: + try: + os.remove(icon_path) + except OSError: + pass + pixbuf = self._missing_icon + use_missing = True + else: + pixbuf = self._missing_icon + + event_image = Gtk.EventBox() + image = Gtk.Image.new_from_pixbuf(pixbuf) + event_image.add(image) + event_image.connect("button-press-event", self._on_image_clicked) + event_image.connect("leave-notify-event", self._on_image_leave) + event_image.connect("enter-notify-event", self._on_image_enter) + hbox.pack_start(event_image, False, False, 2) + + right_vbox = Gtk.VBox() + right_align = Gtk.Alignment() + right_align.set_padding(0, 0, 8, 0) + right_align.add(right_vbox) + + ts_id = Document.DOCUMENT_TIMESTAMP_ID + user_id = DocumentFactory.DOCUMENT_USERNAME_ID + label = Gtk.Label() + time_str = entropy.tools.convert_unix_time_to_human_time( + self._image[ts_id]) + time_str = escape_markup(time_str) + label.set_markup( + "%s" % (escape_markup(self._image[user_id]),) \ + + ", " + time_str + "" \ + + "") + label.set_line_wrap(True) + label.set_line_wrap_mode(Pango.WrapMode.WORD) + label.set_alignment(0.0, 1.0) + label.set_selectable(True) + label.set_name("image-box-author") + right_vbox.pack_start(label, False, False, 0) + + # title, keywords, ddata, document_id + title = self._image['title'].strip() + + if title: + title_id = Document.DOCUMENT_TITLE_ID + label = Gtk.Label() + label.set_markup( + "" + escape_markup(self._image[title_id]) + "") + label.set_name("image-box-title") + label.set_line_wrap(True) + label.set_line_wrap_mode(Pango.WrapMode.WORD) + label.set_alignment(0.0, 0.0) + label.set_selectable(True) + right_vbox.pack_start(label, False, False, 0) + + desc_id = Document.DOCUMENT_DESCRIPTION_ID + label = Gtk.Label() + label_align = Gtk.Alignment() + label_align.set_padding(0, 5, 0, 0) + label_align.add(label) + label.set_markup( + "" + escape_markup(self._image[desc_id]) + "") + label.set_name("image-box-description") + label.set_line_wrap(True) + label.set_line_wrap_mode(Pango.WrapMode.WORD) + label.set_alignment(0.0, 0.0) + label.set_selectable(True) + right_vbox.pack_start(label_align, False, False, 0) + + keywords_id = Document.DOCUMENT_KEYWORDS_ID + label = Gtk.Label() + keywords_txt = "%s: " % (escape_markup(_("Keywords")),) + label.set_markup( + "" + keywords_txt + \ + escape_markup(self._image[keywords_id]) + "") + label.set_name("image-box-keywords") + label.set_line_wrap(True) + label.set_line_wrap_mode(Pango.WrapMode.WORD) + label.set_alignment(0.0, 0.0) + label.set_selectable(True) + right_vbox.pack_start(label, False, False, 0) + + hbox.pack_start(right_align, True, True, 0) + + self.pack_start(vbox, False, False, 0) + vbox.show_all() diff --git a/rigo/rigo_app.py b/rigo/rigo_app.py index f49ab0de9..5eb9f6d33 100644 --- a/rigo/rigo_app.py +++ b/rigo/rigo_app.py @@ -47,6 +47,7 @@ from rigo.ui.gtk3.widgets.notifications import NotificationBox, \ from rigo.ui.gtk3.widgets.welcome import WelcomeBox from rigo.ui.gtk3.widgets.stars import ReactiveStar from rigo.ui.gtk3.widgets.comments import CommentBox +from rigo.ui.gtk3.widgets.images import ImageBox from rigo.ui.gtk3.models.appliststore import AppListStore from rigo.ui.gtk3.utils import init_sc_css_provider, get_sc_icon_theme from rigo.utils import build_application_store_url, build_register_url, \ @@ -425,6 +426,9 @@ class ApplicationViewController(GObject.Object): self._stars_container.pack_start(self._stars_alignment, False, False, 0) + self._app_images_box = self._builder.get_object( + "appViewImagesVbox") + def set_notification_controller(self, nc): """ Bind NotificationController object to this class. @@ -521,7 +525,7 @@ class ApplicationViewController(GObject.Object): self._on_stars_clicked(self._stars, app=app) box.add_button(_("_Vote now"), _send_vote) - box.add_destroy_button(_("_Abort")) + box.add_destroy_button(_("_Later")) self._nc.append(box) def _on_stars_login_failed(self, widget, app): @@ -556,7 +560,7 @@ class ApplicationViewController(GObject.Object): self._logout_webservice(app, _send_vote) box.add_button(_("_No, logout!"), _logout_webservice) - box.add_destroy_button(_("_Abort")) + box.add_destroy_button(_("_Later")) self._nc.append(box) def _vote_submit(self, app, username, vote): @@ -640,6 +644,8 @@ class ApplicationViewController(GObject.Object): going to hide. """ self._last_app = None + for child in self._app_my_comments_box.get_children(): + child.destroy() self.emit("application-hide", self) def _on_send_comment(self, widget, app=None): @@ -703,16 +709,16 @@ class ApplicationViewController(GObject.Object): context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) def _comment_submit(widget): - #box.destroy() self._comment_submit(app, username, text) box.add_button(_("_Ok, cool!"), _comment_submit) + def _send_comment(): + self._on_send_comment(None, app=app) def _logout_webservice(widget): - #box.destroy() - self._logout_webservice(app) + self._logout_webservice(app, _send_comment) box.add_button(_("_No, logout!"), _logout_webservice) - box.add_destroy_button(_("_Abort")) + box.add_destroy_button(_("_Later")) self._nc.append(box) def _comment_submit(self, app, username, text): @@ -811,7 +817,7 @@ class ApplicationViewController(GObject.Object): def _send_comment(widget): self._on_send_comment(widget, app=app) box.add_button(_("_Send now"), _send_comment) - box.add_destroy_button(_("_Abort")) + box.add_destroy_button(_("_Later")) self._nc.append(box) def _on_comment_login_failed(self, widget, app): @@ -879,6 +885,60 @@ class ApplicationViewController(GObject.Object): GLib.idle_add(self._append_comments, downloader, app, comments, has_more) + def _append_images(self, downloader, app, images, has_more): + """ + Append given Entropy WebService Document objects to + the images area. + """ + # remove spinner if there, ugly O(n) + for child in self._app_images_box.get_children(): + if not isinstance(child, ImageBox): + child.destroy() + + if not images: + label = Gtk.Label() + label.set_markup( + _("No images for this Application, yet!")) + self._app_images_box.pack_start(label, False, False, 1) + label.show() + return + + if has_more: + button_box = Gtk.HButtonBox() + button = Gtk.Button() + button.set_label(_("Older images")) + button.set_alignment(0.5, 0.5) + def _enqueue_download(widget): + widget.get_parent().destroy() + spinner = Gtk.Spinner() + spinner.set_size_request(24, 24) + spinner.set_tooltip_text(_("Loading older images...")) + spinner.set_name("image-box-spinner") + self._app_images_box.pack_end(spinner, False, False, 3) + spinner.show() + downloader.enqueue_download() + button.connect("clicked", _enqueue_download) + + button_box.pack_start(button, False, False, 0) + self._app_images_box.pack_start(button_box, False, False, 1) + button_box.show_all() + + idx = 0 + length = len(images) + for doc in images: + idx += 1 + box = ImageBox(doc, is_last=(not has_more and (idx == length))) + box.render() + self._app_images_box.pack_end(box, False, False, 2) + box.show() + + def _append_images_safe(self, downloader, app, comments, has_more): + """ + Same as _append_images() but thread-safe. + """ + GLib.idle_add(self._append_images, downloader, app, + comments, has_more) + def _setup_application_stats(self, stats, icon): """ Setup widgets related to Application statistics (and icon). @@ -933,23 +993,30 @@ class ApplicationViewController(GObject.Object): spinner.show() spinner.start() - downloader = ApplicationViewController.CommentsDownloader( - app, self, self._append_comments_safe) + downloader = ApplicationViewController.MetadataDownloader( + app, self, self._append_comments_safe, + app.download_comments) + downloader.start() + + downloader = ApplicationViewController.MetadataDownloader( + app, self, self._append_images_safe, + app.download_images) downloader.start() self.emit("application-show", app) - class CommentsDownloader(GObject.Object): + class MetadataDownloader(GObject.Object): """ Automated Application comments downloader. """ - def __init__(self, app, avc, callback): + def __init__(self, app, avc, callback, app_downloader_method): self._app = app self._avc = avc self._offset = 0 self._callback = callback self._task = ParallelTask(self._download) + self._app_downloader = app_downloader_method def start(self): """ @@ -961,7 +1028,7 @@ class ApplicationViewController(GObject.Object): def _download_callback(self, document_list): """ - Callback called by download_comments() once data + Callback called by download_() once data is arrived from web service. document_list can be None! """ @@ -976,12 +1043,12 @@ class ApplicationViewController(GObject.Object): if const_debug_enabled(): const_debug_write( __name__, - "CommentsDownloader._download_callback: %s, more: %s" % ( + "MetadataDownloader._download_callback: %s, more: %s" % ( document_list, has_more)) if document_list is not None: const_debug_write( __name__, - "CommentsDownloader._download_callback: " + "MetadataDownloader._download_callback: " "total: %s, offset: %s" % ( document_list.total(), document_list.offset())) @@ -989,13 +1056,13 @@ class ApplicationViewController(GObject.Object): def reset_offset(self): """ - Reset Comments download offset to 0. + Reset Metadata download offset to 0. """ self._offset = 0 def get_offset(self): """ - Get current Comments download offset. + Get current Metadata download offset. """ return self._offset @@ -1008,10 +1075,10 @@ class ApplicationViewController(GObject.Object): def _download(self): """ - Thread body of the initial Comments downloader. + Thread body of the initial Metadata downloader. """ - self._app.download_comments(self._download_callback, - offset=self._offset) + self._app_downloader(self._download_callback, + offset=self._offset) class Rigo(Gtk.Application):