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
- True
- 2
-
+
@@ -409,6 +400,42 @@
6
+
+
+
+ True
+ True
+ 7
+
+
+
+
+
+ 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):