diff --git a/rigo/rigo/ui/gtk3/controllers/__init__.py b/rigo/rigo/ui/gtk3/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rigo/rigo/ui/gtk3/controllers/application.py b/rigo/rigo/ui/gtk3/controllers/application.py new file mode 100644 index 000000000..15afdc7c3 --- /dev/null +++ b/rigo/rigo/ui/gtk3/controllers/application.py @@ -0,0 +1,920 @@ +# -*- 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 +""" + +from gi.repository import Gtk, Gdk, GLib, GObject + +from rigo.enums import AppActions +from rigo.ui.gtk3.widgets.notifications import NotificationBox, \ + LoginNotificationBox +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.utils import build_application_store_url, \ + escape_markup, prepare_markup + +from entropy.const import etpConst, const_debug_write, \ + const_debug_enabled, const_convert_to_unicode, const_isunicode +from entropy.misc import ParallelTask +from entropy.i18n import _ + +import entropy.tools + +class ApplicationViewController(GObject.Object): + """ + Applications View Container, exposing all the events + that can happen to Applications listed in the contained + TreeView. + """ + + class WindowedReactiveStar(ReactiveStar): + + def __init__(self, window): + self._window = window + self._hand = Gdk.Cursor.new(Gdk.CursorType.HAND2) + ReactiveStar.__init__(self) + + def on_enter_notify(self, widget, event): + self._window.get_window().set_cursor(self._hand) + + def on_leave_notify(self, widget, event): + self._window.get_window().set_cursor(None) + + __gsignals__ = { + # Double click on application widget + "application-activated" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # Show Application in the Rigo UI + "application-show" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # Hide Application in the Rigo UI + "application-hide" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # Single click on application widget + "application-selected" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # action requested for application + "application-request-action" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT, + str), + ), + } + + VOTE_NOTIFICATION_CONTEXT_ID = "VoteNotificationContext" + COMMENT_NOTIFICATION_CONTEXT_ID = "CommentNotificationContext" + + def __init__(self, entropy_client, entropy_ws, builder): + GObject.Object.__init__(self) + self._builder = builder + self._entropy = entropy_client + self._entropy_ws = entropy_ws + self._app_store = None + self._last_app = None + self._nc = None + self._avc = None + + self._window = self._builder.get_object("rigoWindow") + self._image = self._builder.get_object("appViewImage") + self._app_name_lbl = self._builder.get_object("appViewNameLabel") + self._app_info_lbl = self._builder.get_object("appViewInfoLabel") + self._app_downloaded_lbl = self._builder.get_object( + "appViewDownloadedLabel") + self._app_comments_box = self._builder.get_object("appViewCommentsVbox") + self._app_comments_box.set_name("comments-box") + self._app_comments_align = self._builder.get_object( + "appViewCommentsAlign") + self._app_my_comments_box = self._builder.get_object( + "appViewMyCommentsVbox") + self._app_my_comments_align = self._builder.get_object( + "appViewMyCommentsAlign") + self._app_my_comments_box.set_name("comments-box") + self._app_comment_send_button = self._builder.get_object( + "appViewCommentSendButton") + self._app_comment_text_view = self._builder.get_object( + "appViewCommentText") + self._app_comment_text_view.set_name("rigo-text-view") + self._app_comment_text_buffer = self._builder.get_object( + "appViewCommentTextBuffer") + self._app_comment_more_label = self._builder.get_object( + "appViewCommentMoreLabel") + self._stars_container = self._builder.get_object("appViewStarsSelVbox") + self._app_button_area = self._builder.get_object("appViewButtonArea") + + self._stars = ApplicationViewController.WindowedReactiveStar( + self._window) + self._stars_alignment = Gtk.Alignment.new(0.0, 0.5, 1.0, 1.0) + self._stars_alignment.set_padding(0, 5, 0, 0) + self._stars_alignment.add(self._stars) + self._stars.set_size_as_pixel_value(24) + + 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. + """ + self._nc = nc + + def set_applications_controller(self, avc): + """ + Bind ApplicationsViewController object to this class. + """ + self._avc = avc + + def set_store(self, store): + """ + Bind AppListStore object to this class. + """ + self._app_store = store + + def setup(self): + self.connect("application-activated", self._on_application_activated) + self._app_store.connect("redraw-request", self._on_redraw_request) + self._app_comment_send_button.connect("clicked", self._on_send_comment) + self._app_comment_send_button.set_sensitive(False) + self._app_comment_text_buffer.connect( + "changed", self._on_comment_buffer_changed) + self._stars.connect("changed", self._on_stars_clicked) + + def _on_comment_buffer_changed(self, widget): + """ + Our comment text is changed, decide if to activate the Send button. + """ + count = self._app_comment_text_buffer.get_char_count() + found = count != 0 + self._app_comment_send_button.set_sensitive(found) + + def _on_application_activated(self, avc, app): + """ + Event received from Gtk widgets requesting us to load package + information. Once we're done loading the shit, we just emit + 'application-show' and let others do the UI switch. + """ + self._last_app = app + task = ParallelTask(self.__application_activate, app) + task.name = "ApplicationActivate" + task.daemon = True + task.start() + + def _on_redraw_request(self, widget, pkg_match): + """ + Redraw request received from AppListStore for given package match. + We are required to update rating, number of downloads, icon. + """ + if self._last_app is None: + return + if pkg_match == self._last_app.get_details().pkg: + stats = self._app_store.get_review_stats(pkg_match) + icon = self._app_store.get_icon(pkg_match) + self._setup_application_stats(stats, icon) + if self._app_store is not None: + self._app_store.emit("redraw-request", self._app_store) + + def _on_stars_clicked(self, widget, app=None): + """ + Stars clicked, user wants to vote. + """ + if app is None: + app = self._last_app + if app is None: + # wtf + return + + def _sender(app, vote): + if not app.is_webservice_available(): + GLib.idle_add(self._notify_webservice_na, app, + self.VOTE_NOTIFICATION_CONTEXT_ID) + return + ws_user = app.get_webservice_username() + if ws_user is not None: + GLib.idle_add(self._notify_vote_submit, app, ws_user, vote) + else: + GLib.idle_add(self._notify_login_request, app, vote, + self._on_stars_login_success, + self._on_stars_login_failed, + self.VOTE_NOTIFICATION_CONTEXT_ID) + + vote = int(self._stars.get_rating()) # is float + task = ParallelTask(_sender, app, vote) + task.name = "AppViewSendVote" + task.start() + + def _on_stars_login_success(self, widget, username, app): + """ + Notify user that we successfully logged in! + """ + box = NotificationBox( + _("Logged in as %s! How about your vote?") \ + % (escape_markup(username),), + message_type=Gtk.MessageType.INFO, + context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) + + def _send_vote(widget): + self._on_stars_clicked(self._stars, app=app) + box.add_button(_("_Vote now"), _send_vote) + + box.add_destroy_button(_("_Later")) + self._nc.append(box) + + def _on_stars_login_failed(self, widget, app): + """ + Entropy Web Services Login failed message. + """ + box = NotificationBox( + _("Login failed. Your vote hasn't been added"), + message_type=Gtk.MessageType.ERROR, + context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) + box.add_destroy_button(_("_Ok, thanks")) + self._nc.append(box) + + def _notify_vote_submit(self, app, username, vote): + """ + Notify User about Comment submission with current credentials. + """ + box = NotificationBox( + _("Rate %s as %s, with %d stars?") \ + % (app.name, escape_markup(username), + vote,), + message_type=Gtk.MessageType.INFO, + context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) + + def _vote_submit(widget): + self._vote_submit(app, username, vote) + box.add_button(_("_Ok, cool!"), _vote_submit) + + def _send_vote(): + self._on_stars_clicked(self._stars, app=app) + def _logout_webservice(widget): + self._logout_webservice(app, _send_vote) + box.add_button(_("_No, logout!"), _logout_webservice) + + box.add_destroy_button(_("_Later")) + self._nc.append(box) + + def _vote_submit(self, app, username, vote): + """ + Do the actual vote submit. + """ + task = ParallelTask( + self._vote_submit_thread_body, + app, username, vote) + task.name = "VoteSubmitThreadBody" + task.daemon = True + task.start() + + def _vote_submit_thread_body(self, app, username, vote): + """ + Called by _vote_submit(), does the actualy submit. + """ + repository_id = app.get_details().channelname + webserv = self._entropy_ws.get(repository_id) + if webserv is None: + # impossible! + return + + key = app.get_details().pkgkey + + err_msg = None + try: + voted = webserv.add_vote( + key, vote) + except WebService.WebServiceException as err: + voted = False + err_msg = str(err) + + def _submit_success(): + nbox = NotificationBox( + _("Your vote has been added!"), + message_type=Gtk.MessageType.INFO, + context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) + nbox.add_destroy_button(_("Ok, great!")) + self._nc.append(nbox, timeout=10) + self._on_redraw_request(None, app.get_details().pkg) + + def _submit_fail(err_msg): + if err_msg is None: + box = NotificationBox( + _("You already voted this Application"), + message_type=Gtk.MessageType.ERROR, + context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) + else: + box = NotificationBox( + _("Vote error: %s") % (err_msg,), + message_type=Gtk.MessageType.ERROR, + context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) + box.add_destroy_button(_("Ok, thanks")) + self._nc.append(box) + + if voted: + GLib.idle_add(_submit_success) + else: + GLib.idle_add(_submit_fail, err_msg) + + def __application_activate(self, app): + """ + Collect data from app, then call the UI setup in the main loop. + """ + details = app.get_details() + metadata = {} + metadata['markup'] = app.get_extended_markup() + metadata['info'] = app.get_info_markup() + metadata['download_size'] = details.downsize + metadata['stats'] = app.get_review_stats() + metadata['homepage'] = details.website + metadata['date'] = details.date + # using app store here because we cache the icon pixbuf + metadata['icon'] = self._app_store.get_icon(details.pkg) + metadata['is_installed'] = app.is_installed() + metadata['is_updatable'] = app.is_updatable() + GLib.idle_add(self._setup_application_info, app, metadata) + + def hide(self): + """ + This method shall be called when the Controller widgets are + going to hide. + """ + self._last_app = None + for child in self._app_my_comments_box.get_children(): + child.destroy() + for child in self._app_images_box.get_children(): + child.destroy() + + self.emit("application-hide", self) + + def _on_send_comment(self, widget, app=None): + """ + Send comment to Web Service. + """ + if app is None: + app = self._last_app + if app is None: + # we're hiding + return + + text = self._app_comment_text_buffer.get_text( + self._app_comment_text_buffer.get_start_iter(), + self._app_comment_text_buffer.get_end_iter(), + False) + if not text.strip(): + return + # make it utf-8 + text = const_convert_to_unicode(text, enctype=etpConst['conf_encoding']) + + def _sender(app, text): + if not app.is_webservice_available(): + GLib.idle_add(self._notify_webservice_na, app, + self.COMMENT_NOTIFICATION_CONTEXT_ID) + return + ws_user = app.get_webservice_username() + if ws_user is not None: + GLib.idle_add(self._notify_comment_submit, app, ws_user, text) + else: + GLib.idle_add(self._notify_login_request, app, text, + self._on_comment_login_success, + self._on_comment_login_failed, + self.COMMENT_NOTIFICATION_CONTEXT_ID) + + task = ParallelTask(_sender, app, text) + task.name = "AppViewSendComment" + task.start() + + def _notify_webservice_na(self, app, context_id): + """ + Notify Web Service unavailability for given Application object. + """ + box = NotificationBox( + "%s: %s" % ( + _("Entropy Web Services not available for repository"), + app.get_details().channelname), + message_type=Gtk.MessageType.ERROR, + context_id=context_id) + box.add_destroy_button(_("Ok, thanks")) + self._nc.append(box) + + def _notify_comment_submit(self, app, username, text): + """ + Notify User about Comment submission with current credentials. + """ + box = NotificationBox( + _("You are about to add a comment as %s.") \ + % (escape_markup(username),), + message_type=Gtk.MessageType.INFO, + context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) + + def _comment_submit(widget): + 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): + self._logout_webservice(app, _send_comment) + box.add_button(_("_No, logout!"), _logout_webservice) + + box.add_destroy_button(_("_Later")) + self._nc.append(box) + + def _comment_submit(self, app, username, text): + """ + Actual Comment submit to Web Service. + Here we arrive from the MainThread. + """ + task = ParallelTask( + self._comment_submit_thread_body, + app, username, text) + task.name = "CommentSubmitThreadBody" + task.daemon = True + task.start() + + def _comment_submit_thread_body(self, app, username, text): + """ + Called by _comment_submit(), does the actualy submit. + """ + repository_id = app.get_details().channelname + webserv = self._entropy_ws.get(repository_id) + if webserv is None: + # impossible! + return + + key = app.get_details().pkgkey + doc_factory = webserv.document_factory() + doc = doc_factory.comment( + username, text, "", "") + + err_msg = None + try: + new_doc = webserv.add_document(key, doc) + except WebService.WebServiceException as err: + new_doc = None + err_msg = str(err) + + def _submit_success(doc): + box = CommentBox(self._nc, self._avc, webserv, doc, is_last=True) + box.connect("destroy", self._on_comment_box_destroy) + + self.__clean_my_non_comment_boxes() + box.render() + self._app_my_comments_box.pack_start(box, False, False, 2) + box.show() + self._app_my_comments_box.show() + + nbox = NotificationBox( + _("Your comment has been submitted!"), + message_type=Gtk.MessageType.INFO, + context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) + nbox.add_destroy_button(_("Ok, great!")) + self._app_comment_text_buffer.set_text("") + self._nc.append(nbox, timeout=10) + + def _submit_fail(): + box = NotificationBox( + _("Comment submit error: %s") % (err_msg,), + message_type=Gtk.MessageType.ERROR, + context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) + box.add_destroy_button(_("Ok, thanks")) + self._nc.append(box) + + if new_doc is not None: + GLib.idle_add(_submit_success, new_doc) + else: + GLib.idle_add(_submit_fail) + + def _logout_webservice(self, app, reinit_callback): + """ + Execute logout of current credentials from Web Service. + Actually, this means removing the local cookie. + """ + repository_id = app.get_details().channelname + webserv = self._entropy_ws.get(repository_id) + if webserv is not None: + webserv.remove_credentials() + + GLib.idle_add(self._avc.emit, "logged-out") + GLib.idle_add(reinit_callback) + + def _notify_login_request(self, app, text, on_success, on_fail, + context_id): + """ + Notify User that login is required + """ + box = LoginNotificationBox( + self._avc, self._entropy_ws, app, + context_id=context_id) + box.connect("login-success", on_success) + box.connect("login-failed", on_fail) + self._nc.append(box) + + def _on_comment_login_success(self, widget, username, app): + """ + Notify user that we successfully logged in! + """ + box = NotificationBox( + _("Logged in as %s! How about your comment?") \ + % (escape_markup(username),), + message_type=Gtk.MessageType.INFO, + context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) + def _send_comment(widget): + self._on_send_comment(widget, app=app) + box.add_button(_("_Send now"), _send_comment) + box.add_destroy_button(_("_Later")) + self._nc.append(box) + + def _on_comment_login_failed(self, widget, app): + """ + Entropy Web Services Login failed message. + """ + box = NotificationBox( + _("Login failed. Your comment hasn't been added"), + message_type=Gtk.MessageType.ERROR, + context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) + box.add_destroy_button(_("_Ok, thanks")) + self._nc.append(box) + + def _on_comment_box_destroy(self, widget): + """ + Called when a CommentBox is destroyed. + We need to figure out if there are CommentBoxes left and in case + show the "no comments available" message. + """ + children = self._app_comments_box.get_children() + if not children: + self.__show_no_comments() + + def __show_no_comments(self): + """ + Create "No comments for this Application" message. + """ + label = Gtk.Label() + label.set_markup( + prepare_markup( + _("No comments for this Application, yet!"))) + # place in app_my, this way it will get cleared out + # once a new comment is inserted + self._app_my_comments_box.pack_start(label, False, False, 1) + self._app_my_comments_box.show_all() + + def __clean_non_comment_boxes(self): + """ + Remove children that are not CommentBox objects from + self._app_comments_box + """ + for child in self._app_comments_box.get_children(): + if not isinstance(child, CommentBox): + child.destroy() + + def __clean_my_non_comment_boxes(self): + """ + Remove children that are not CommentBox objects from + self._app_my_comments_box + """ + for child in self._app_my_comments_box.get_children(): + if not isinstance(child, CommentBox): + child.destroy() + + def __clean_non_image_boxes(self): + """ + Remove children that are not ImageBox objects from + self._app_images_box + """ + for child in self._app_images_box.get_children(): + if not isinstance(child, ImageBox): + child.destroy() + + def _append_comments(self, downloader, app, comments, has_more): + """ + Append given Entropy WebService Document objects to + the comment area. + """ + self.__clean_non_comment_boxes() + # make sure we didn't leave stuff here as well + self.__clean_my_non_comment_boxes() + + if not comments: + self.__show_no_comments() + return + + if has_more: + button_box = Gtk.HButtonBox() + button = Gtk.Button() + button.set_label(_("Older comments")) + 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 comments...")) + spinner.set_name("comment-box-spinner") + self._app_comments_box.pack_end(spinner, False, False, 3) + spinner.show() + spinner.start() + downloader.enqueue_download() + button.connect("clicked", _enqueue_download) + + button_box.pack_start(button, False, False, 0) + self._app_comments_box.pack_start(button_box, False, False, 1) + button_box.show_all() + + idx = 0 + length = len(comments) + # can be None + webserv = self._entropy_ws.get(app.get_details().channelname) + for doc in comments: + idx += 1 + box = CommentBox( + self._nc, self._avc, webserv, doc, + is_last=(not has_more and (idx == length))) + box.connect("destroy", self._on_comment_box_destroy) + box.render() + self._app_comments_box.pack_end(box, False, False, 2) + box.show() + + def _append_comments_safe(self, downloader, app, comments, has_more): + """ + Same as _append_comments() but thread-safe. + """ + 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. + """ + self.__clean_non_image_boxes() + + if not images: + label = Gtk.Label() + label.set_markup( + prepare_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() + spinner.start() + 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 _on_app_remove(self, widget, app): + """ + Remove the given Application. + """ + self.emit("application-request-action", + app, AppActions.REMOVE) + + def _on_app_install(self, widget, app): + """ + Install (or reinstall) the given Application. + """ + self.emit("application-request-action", + app, AppActions.INSTALL) + + def _setup_buttons(self, app, is_installed, is_updatable): + """ + Setup Application View Buttons (Install/Remove/Update). + """ + button_area = self._app_button_area + for child in button_area.get_children(): + child.destroy() + + if is_installed: + if is_updatable: + update_button = Gtk.Button() + update_button.set_label( + escape_markup(_("Update"))) + def _on_app_update(widget): + return self._on_app_install(widget, app) + update_button.connect("clicked", _on_app_update) + button_area.pack_start(update_button, False, False, 0) + else: + reinstall_button = Gtk.Button() + reinstall_button.set_label( + escape_markup(_("Reinstall"))) + def _on_app_reinstall(widget): + return self._on_app_install(widget, app) + reinstall_button.connect("clicked", _on_app_reinstall) + button_area.pack_start(reinstall_button, False, False, 0) + + remove_button = Gtk.Button() + remove_button.set_label( + escape_markup(_("Remove"))) + def _on_app_remove(widget): + return self._on_app_remove(widget, app) + remove_button.connect("clicked", _on_app_remove) + button_area.pack_start(remove_button, False, False, 0) + + else: + install_button = Gtk.Button() + install_button.set_label( + escape_markup(_("Install"))) + def _on_app_install(widget): + return self._on_app_install(widget, app) + install_button.connect("clicked", _on_app_install) + button_area.pack_start(install_button, False, False, 0) + + button_area.show_all() + + def _setup_application_stats(self, stats, icon): + """ + Setup widgets related to Application statistics (and icon). + """ + total_downloads = stats.downloads_total + if total_downloads < 0: + down_msg = escape_markup(_("Not available")) + elif not total_downloads: + down_msg = escape_markup(_("Never downloaded")) + else: + down_msg = "%s %s" % ( + stats.downloads_total_markup, + escape_markup(_("downloads")),) + + self._app_downloaded_lbl.set_markup(down_msg) + if icon: + self._image.set_from_pixbuf(icon) + self._stars.set_rating(stats.ratings_average) + self._stars_alignment.show_all() + + def _setup_application_info(self, app, metadata): + """ + Setup the actual UI widgets content and emit 'application-show' + """ + self._app_name_lbl.set_markup(metadata['markup']) + self._app_info_lbl.set_markup(metadata['info']) + + # install/remove/update buttons + self._setup_buttons( + app, metadata['is_installed'], + metadata['is_updatable']) + + # only comments supported, point to the remote + # www service for the rest + self._app_comment_more_label.set_markup( + "%s: %s" % ( + escape_markup(_("Want to add images, etc?")), + escape_markup(build_application_store_url(app, "ugc")), + escape_markup(_("click here!")),)) + + stats = metadata['stats'] + icon = metadata['icon'] + self._setup_application_stats(stats, icon) + + # load application comments asynchronously + # so at the beginning, just place a spinner + spinner = Gtk.Spinner() + spinner.set_size_request(-1, 48) + spinner.set_tooltip_text(escape_markup(_("Loading comments..."))) + spinner.set_name("comment-box-spinner") + for child in self._app_comments_box.get_children(): + child.destroy() + self._app_comments_box.pack_start(spinner, False, False, 0) + spinner.show() + spinner.start() + + 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 MetadataDownloader(GObject.Object): + """ + Automated Application comments downloader. + """ + + 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): + """ + Start downloading comments and send them to callback. + Loop over until we have more of them to download. + """ + self._offset = 0 + self._task.start() + + def _download_callback(self, document_list): + """ + Callback called by download_() once data + is arrived from web service. + document_list can be None! + """ + has_more = 0 + if document_list is not None: + has_more = document_list.has_more() + # stash more data? + if has_more and (document_list is not None): + self._offset += len(document_list) + # download() will be called externally + + if const_debug_enabled(): + const_debug_write( + __name__, + "MetadataDownloader._download_callback: %s, more: %s" % ( + document_list, has_more)) + if document_list is not None: + const_debug_write( + __name__, + "MetadataDownloader._download_callback: " + "total: %s, offset: %s" % ( + document_list.total(), document_list.offset())) + + self._callback(self, self._app, document_list, has_more) + + def reset_offset(self): + """ + Reset Metadata download offset to 0. + """ + self._offset = 0 + + def get_offset(self): + """ + Get current Metadata download offset. + """ + return self._offset + + def enqueue_download(self): + """ + Enqueue a new download, starting from current offset + """ + self._task = ParallelTask(self._download) + self._task.start() + + def _download(self): + """ + Thread body of the initial Metadata downloader. + """ + self._app_downloader(self._download_callback, + offset=self._offset) diff --git a/rigo/rigo/ui/gtk3/controllers/applications.py b/rigo/rigo/ui/gtk3/controllers/applications.py new file mode 100644 index 000000000..c955114fb --- /dev/null +++ b/rigo/rigo/ui/gtk3/controllers/applications.py @@ -0,0 +1,297 @@ +# -*- 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 copy +import dbus + +from gi.repository import Gtk, GLib, GObject + +from rigo.models.application import Application, ApplicationMetadata +from rigo.utils import escape_markup, prepare_markup + +from entropy.const import etpConst, const_debug_write, \ + const_debug_enabled, const_convert_to_unicode +from entropy.misc import ParallelTask +from entropy.i18n import _ + + +class ApplicationsViewController(GObject.Object): + + __gsignals__ = { + # View has been cleared + "view-cleared" : (GObject.SignalFlags.RUN_LAST, + None, + tuple(), + ), + # View has been filled + "view-filled" : (GObject.SignalFlags.RUN_LAST, + None, + tuple(), + ), + # View has been filled + "view-want-change" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # User logged in to Entropy Web Services + "logged-in" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # User logged out from Entropy Web Services + "logged-out" : (GObject.SignalFlags.RUN_LAST, + None, + tuple(), + ), + } + + def __init__(self, activity_rwsem, entropy_client, entropy_ws, + rigo_service, icons, nf_box, + search_entry, store, view): + GObject.Object.__init__(self) + self._activity_rwsem = activity_rwsem + self._entropy = entropy_client + self._service = rigo_service + self._icons = icons + self._entropy_ws = entropy_ws + self._search_entry = search_entry + self._store = store + self._view = view + self._nf_box = nf_box + self._not_found_search_box = None + self._not_found_label = None + + def _search_icon_release(self, search_entry, icon_pos, _other): + """ + Event associated to the Search bar icon click. + Here we catch secondary icon click to reset the search entry text. + """ + if search_entry is not self._search_entry: + return + if icon_pos == Gtk.EntryIconPosition.SECONDARY: + search_entry.set_text("") + self.clear() + search_entry.emit("changed") + elif self._store.get_iter_first(): + # primary icon click will force UI to switch to Browser mode + self.emit("view-filled") + + def _search_changed(self, search_entry): + GLib.timeout_add(700, self._search, search_entry.get_text()) + + def _search(self, old_text): + cur_text = self._search_entry.get_text() + if cur_text == old_text and cur_text: + search_text = copy.copy(old_text) + search_text = const_convert_to_unicode( + search_text, enctype=etpConst['conf_encoding']) + th = ParallelTask(self.__search_thread, search_text) + th.name = "SearchThread" + th.start() + + def __search_thread(self, text): + def _prepare_for_search(txt): + return txt.replace(" ", "-").lower() + + ## special keywords hook + if text == "rigo:update": + self._update_repositories_safe() + return + if text == "rigo:vte": + GLib.idle_add(self.emit, "view-want-change", Rigo.WORK_VIEW_STATE) + return + if text == "rigo:output": + GLib.idle_add(self.emit, "view-want-change", Rigo.WORK_VIEW_STATE) + GLib.idle_add(self._service.output_test) + return + + # Do not execute search if repositories are + # being hold by other write + self._activity_rwsem.reader_acquire() + try: + + matches = [] + + # exact match + pkg_matches, rc = self._entropy.atom_match( + text, multi_match = True, + multi_repo = True, mask_filter = False) + matches.extend(pkg_matches) + + # atom searching (name and desc) + search_matches = self._entropy.atom_search( + text, + repositories = self._entropy.repositories(), + description = True) + + matches.extend([x for x in search_matches if x not in matches]) + + if not search_matches: + search_matches = self._entropy.atom_search( + _prepare_for_search(text), + repositories = self._entropy.repositories()) + matches.extend([x for x in search_matches if x not in matches]) + + # we have to decide if to show the treeview in + # the UI thread, to avoid races (and also because we + # have to...) + self.set_many_safe(matches, _from_search=text) + + finally: + self._activity_rwsem.reader_release() + + def _setup_search_view(self, items_count, text): + """ + Setup UI in order to show a "not found" message if required. + """ + nf_box = self._not_found_box + if items_count: + nf_box.set_property("expand", False) + nf_box.hide() + self._view.get_parent().show() + else: + self._view.get_parent().hide() + self._setup_not_found_box(text) + nf_box.set_property("expand", True) + nf_box.show() + + def _setup_not_found_box(self, search_text): + """ + Setup "not found" message label and layout + """ + nf_box = self._not_found_box + # now self._not_found_label is available + meant_packages = self._entropy.get_meant_packages( + search_text) + text = escape_markup(search_text) + + msg = "%s %s" % ( + escape_markup(_("Nothing found for")), + text,) + if meant_packages: + first_entry = meant_packages[0] + app = Application( + self._entropy, self._entropy_ws, + first_entry) + name = app.name + + msg += ", %s" % ( + prepare_markup(_("did you mean %s?")) % ( + escape_markup(name), + escape_markup(name),),) + + self._not_found_label.set_markup(msg) + + def _on_not_found_label_activate_link(self, label, text): + """ + Handling the click event on of the + "not found" search label. Just write the coming text + to the Gtk.SearchEntry object. + """ + if text: + self._search_entry.set_text(text) + self._search(text) + + @property + def _not_found_box(self): + """ + Return a Gtk.VBox containing the view that should + be shown when no apps have been found (due to a search). + """ + if self._not_found_search_box is not None: + return self._not_found_search_box + # here we always have to access from the same thread + # otherwise Gtk will go boom anyway + box_align = Gtk.Alignment() + box_align.set_padding(10, 10, 0, 0) + box = Gtk.VBox() + box_align.add(box) + label = Gtk.Label(_("Not found")) + label.connect("activate-link", self._on_not_found_label_activate_link) + box.pack_start(label, True, True, 0) + box_align.show() + + self._nf_box.pack_start(box_align, False, False, 0) + self._nf_box.show_all() + self._not_found_label = label + self._not_found_search_box = box_align + return box_align + + def _update_repositories(self): + """ + Spawn Repository Update on RigoDaemon + """ + self._service.update_repositories([], True) + + def _update_repositories_safe(self): + """ + Same as _update_repositories() but thread safe. + """ + GLib.idle_add(self._update_repositories) + + def setup(self): + self._view.set_model(self._store) + self._search_entry.connect( + "changed", self._search_changed) + self._search_entry.connect("icon-release", + self._search_icon_release) + self._view.show() + + def clear(self): + self._store.clear() + ApplicationMetadata.discard() + if const_debug_enabled(): + const_debug_write(__name__, "AVC: emitting view-cleared") + self.emit("view-cleared") + + def append(self, opaque): + self._store.append([opaque]) + if const_debug_enabled(): + const_debug_write(__name__, "AVC: emitting view-filled") + self.emit("view-filled") + + def append_many(self, opaque_list): + for opaque in opaque_list: + self._store.append([opaque]) + if const_debug_enabled(): + const_debug_write(__name__, "AVC: emitting view-filled") + self.emit("view-filled") + + def set_many(self, opaque_list, _from_search=None): + self._store.clear() + ApplicationMetadata.discard() + self.append_many(opaque_list) + if _from_search: + self._setup_search_view( + len(opaque_list), _from_search) + + def clear_safe(self): + GLib.idle_add(self.clear) + + def append_safe(self, opaque): + GLib.idle_add(self.append, opaque) + + def append_many_safe(self, opaque_list): + GLib.idle_add(self.append_many, opaque_list) + + def set_many_safe(self, opaque_list, _from_search=None): + GLib.idle_add(self.set_many, opaque_list, + _from_search) diff --git a/rigo/rigo_app.py b/rigo/rigo_app.py index a43dc1a24..5b8def5f1 100644 --- a/rigo/rigo_app.py +++ b/rigo/rigo_app.py @@ -36,9 +36,8 @@ sys.path.insert(4, "/usr/lib/entropy/client") sys.path.insert(5, "/usr/lib/entropy/rigo") sys.path.insert(6, "/usr/lib/rigo") -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, Gio, GLib, GObject, Vte, Pango +from gi.repository import Gtk, Gdk, Gio, GLib, GObject, Vte, Pango, \ + Polkit from rigo.paths import DATA_DIR from rigo.enums import Icons, AppActions @@ -48,6 +47,8 @@ from rigo.ui.gtk3.widgets.apptreeview import AppTreeView from rigo.ui.gtk3.widgets.notifications import NotificationBox, \ RepositoriesUpdateNotificationBox, UpdatesNotificationBox, \ LoginNotificationBox, ConnectivityNotificationBox +from rigo.ui.gtk3.controllers.applications import ApplicationsViewController +from rigo.ui.gtk3.controllers.application import ApplicationViewController from rigo.ui.gtk3.widgets.welcome import WelcomeBox from rigo.ui.gtk3.widgets.stars import ReactiveStar from rigo.ui.gtk3.widgets.comments import CommentBox @@ -880,270 +881,6 @@ class WorkViewController(GObject.Object): self._terminal.select_all() -class ApplicationsViewController(GObject.Object): - - __gsignals__ = { - # View has been cleared - "view-cleared" : (GObject.SignalFlags.RUN_LAST, - None, - tuple(), - ), - # View has been filled - "view-filled" : (GObject.SignalFlags.RUN_LAST, - None, - tuple(), - ), - # View has been filled - "view-want-change" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT,), - ), - # User logged in to Entropy Web Services - "logged-in" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT,), - ), - # User logged out from Entropy Web Services - "logged-out" : (GObject.SignalFlags.RUN_LAST, - None, - tuple(), - ), - } - - def __init__(self, activity_rwsem, entropy_client, entropy_ws, - rigo_service, icons, nf_box, - search_entry, store, view): - GObject.Object.__init__(self) - self._activity_rwsem = activity_rwsem - self._entropy = entropy_client - self._service = rigo_service - self._icons = icons - self._entropy_ws = entropy_ws - self._search_entry = search_entry - self._store = store - self._view = view - self._nf_box = nf_box - self._not_found_search_box = None - self._not_found_label = None - - def _search_icon_release(self, search_entry, icon_pos, _other): - """ - Event associated to the Search bar icon click. - Here we catch secondary icon click to reset the search entry text. - """ - if search_entry is not self._search_entry: - return - if icon_pos == Gtk.EntryIconPosition.SECONDARY: - search_entry.set_text("") - self.clear() - search_entry.emit("changed") - elif self._store.get_iter_first(): - # primary icon click will force UI to switch to Browser mode - self.emit("view-filled") - - def _search_changed(self, search_entry): - GLib.timeout_add(700, self._search, search_entry.get_text()) - - def _search(self, old_text): - cur_text = self._search_entry.get_text() - if cur_text == old_text and cur_text: - search_text = copy.copy(old_text) - search_text = const_convert_to_unicode( - search_text, enctype=etpConst['conf_encoding']) - th = ParallelTask(self.__search_thread, search_text) - th.name = "SearchThread" - th.start() - - def __search_thread(self, text): - def _prepare_for_search(txt): - return txt.replace(" ", "-").lower() - - ## special keywords hook - if text == "rigo:update": - self._update_repositories_safe() - return - if text == "rigo:vte": - GLib.idle_add(self.emit, "view-want-change", Rigo.WORK_VIEW_STATE) - return - if text == "rigo:output": - GLib.idle_add(self.emit, "view-want-change", Rigo.WORK_VIEW_STATE) - GLib.idle_add(self._service.output_test) - return - - # Do not execute search if repositories are - # being hold by other write - self._activity_rwsem.reader_acquire() - try: - - matches = [] - - # exact match - pkg_matches, rc = self._entropy.atom_match( - text, multi_match = True, - multi_repo = True, mask_filter = False) - matches.extend(pkg_matches) - - # atom searching (name and desc) - search_matches = self._entropy.atom_search( - text, - repositories = self._entropy.repositories(), - description = True) - - matches.extend([x for x in search_matches if x not in matches]) - - if not search_matches: - search_matches = self._entropy.atom_search( - _prepare_for_search(text), - repositories = self._entropy.repositories()) - matches.extend([x for x in search_matches if x not in matches]) - - # we have to decide if to show the treeview in - # the UI thread, to avoid races (and also because we - # have to...) - self.set_many_safe(matches, _from_search=text) - - finally: - self._activity_rwsem.reader_release() - - def _setup_search_view(self, items_count, text): - """ - Setup UI in order to show a "not found" message if required. - """ - nf_box = self._not_found_box - if items_count: - nf_box.set_property("expand", False) - nf_box.hide() - self._view.get_parent().show() - else: - self._view.get_parent().hide() - self._setup_not_found_box(text) - nf_box.set_property("expand", True) - nf_box.show() - - def _setup_not_found_box(self, search_text): - """ - Setup "not found" message label and layout - """ - nf_box = self._not_found_box - # now self._not_found_label is available - meant_packages = self._entropy.get_meant_packages( - search_text) - text = escape_markup(search_text) - - msg = "%s %s" % ( - escape_markup(_("Nothing found for")), - text,) - if meant_packages: - first_entry = meant_packages[0] - app = Application( - self._entropy, self._entropy_ws, - first_entry) - name = app.name - - msg += ", %s" % ( - prepare_markup(_("did you mean %s?")) % ( - escape_markup(name), - escape_markup(name),),) - - self._not_found_label.set_markup(msg) - - def _on_not_found_label_activate_link(self, label, text): - """ - Handling the click event on of the - "not found" search label. Just write the coming text - to the Gtk.SearchEntry object. - """ - if text: - self._search_entry.set_text(text) - self._search(text) - - @property - def _not_found_box(self): - """ - Return a Gtk.VBox containing the view that should - be shown when no apps have been found (due to a search). - """ - if self._not_found_search_box is not None: - return self._not_found_search_box - # here we always have to access from the same thread - # otherwise Gtk will go boom anyway - box_align = Gtk.Alignment() - box_align.set_padding(10, 10, 0, 0) - box = Gtk.VBox() - box_align.add(box) - label = Gtk.Label(_("Not found")) - label.connect("activate-link", self._on_not_found_label_activate_link) - box.pack_start(label, True, True, 0) - box_align.show() - - self._nf_box.pack_start(box_align, False, False, 0) - self._nf_box.show_all() - self._not_found_label = label - self._not_found_search_box = box_align - return box_align - - def _update_repositories(self): - """ - Spawn Repository Update on RigoDaemon - """ - self._service.update_repositories([], True) - - def _update_repositories_safe(self): - """ - Same as _update_repositories() but thread safe. - """ - GLib.idle_add(self._update_repositories) - - def setup(self): - self._view.set_model(self._store) - self._search_entry.connect( - "changed", self._search_changed) - self._search_entry.connect("icon-release", - self._search_icon_release) - self._view.show() - - def clear(self): - self._store.clear() - ApplicationMetadata.discard() - if const_debug_enabled(): - const_debug_write(__name__, "AVC: emitting view-cleared") - self.emit("view-cleared") - - def append(self, opaque): - self._store.append([opaque]) - if const_debug_enabled(): - const_debug_write(__name__, "AVC: emitting view-filled") - self.emit("view-filled") - - def append_many(self, opaque_list): - for opaque in opaque_list: - self._store.append([opaque]) - if const_debug_enabled(): - const_debug_write(__name__, "AVC: emitting view-filled") - self.emit("view-filled") - - def set_many(self, opaque_list, _from_search=None): - self._store.clear() - ApplicationMetadata.discard() - self.append_many(opaque_list) - if _from_search: - self._setup_search_view( - len(opaque_list), _from_search) - - def clear_safe(self): - GLib.idle_add(self.clear) - - def append_safe(self, opaque): - GLib.idle_add(self.append, opaque) - - def append_many_safe(self, opaque_list): - GLib.idle_add(self.append_many, opaque_list) - - def set_many_safe(self, opaque_list, _from_search=None): - GLib.idle_add(self.set_many, opaque_list, - _from_search) - - class NotificationViewController(GObject.Object): """ @@ -1341,889 +1078,6 @@ class NotificationViewController(GObject.Object): GLib.idle_add(self.clear) -class ApplicationViewController(GObject.Object): - """ - Applications View Container, exposing all the events - that can happen to Applications listed in the contained - TreeView. - """ - - class WindowedReactiveStar(ReactiveStar): - - def __init__(self, window): - self._window = window - self._hand = Gdk.Cursor.new(Gdk.CursorType.HAND2) - ReactiveStar.__init__(self) - - def on_enter_notify(self, widget, event): - self._window.get_window().set_cursor(self._hand) - - def on_leave_notify(self, widget, event): - self._window.get_window().set_cursor(None) - - __gsignals__ = { - # Double click on application widget - "application-activated" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT,), - ), - # Show Application in the Rigo UI - "application-show" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT,), - ), - # Hide Application in the Rigo UI - "application-hide" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT,), - ), - # Single click on application widget - "application-selected" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT,), - ), - # action requested for application - "application-request-action" : (GObject.SignalFlags.RUN_LAST, - None, - (GObject.TYPE_PYOBJECT, - str), - ), - } - - VOTE_NOTIFICATION_CONTEXT_ID = "VoteNotificationContext" - COMMENT_NOTIFICATION_CONTEXT_ID = "CommentNotificationContext" - - def __init__(self, entropy_client, entropy_ws, builder): - GObject.Object.__init__(self) - self._builder = builder - self._entropy = entropy_client - self._entropy_ws = entropy_ws - self._app_store = None - self._last_app = None - self._nc = None - self._avc = None - - self._window = self._builder.get_object("rigoWindow") - self._image = self._builder.get_object("appViewImage") - self._app_name_lbl = self._builder.get_object("appViewNameLabel") - self._app_info_lbl = self._builder.get_object("appViewInfoLabel") - self._app_downloaded_lbl = self._builder.get_object( - "appViewDownloadedLabel") - self._app_comments_box = self._builder.get_object("appViewCommentsVbox") - self._app_comments_box.set_name("comments-box") - self._app_comments_align = self._builder.get_object( - "appViewCommentsAlign") - self._app_my_comments_box = self._builder.get_object( - "appViewMyCommentsVbox") - self._app_my_comments_align = self._builder.get_object( - "appViewMyCommentsAlign") - self._app_my_comments_box.set_name("comments-box") - self._app_comment_send_button = self._builder.get_object( - "appViewCommentSendButton") - self._app_comment_text_view = self._builder.get_object( - "appViewCommentText") - self._app_comment_text_view.set_name("rigo-text-view") - self._app_comment_text_buffer = self._builder.get_object( - "appViewCommentTextBuffer") - self._app_comment_more_label = self._builder.get_object( - "appViewCommentMoreLabel") - self._stars_container = self._builder.get_object("appViewStarsSelVbox") - self._app_button_area = self._builder.get_object("appViewButtonArea") - - self._stars = ApplicationViewController.WindowedReactiveStar( - self._window) - self._stars_alignment = Gtk.Alignment.new(0.0, 0.5, 1.0, 1.0) - self._stars_alignment.set_padding(0, 5, 0, 0) - self._stars_alignment.add(self._stars) - self._stars.set_size_as_pixel_value(24) - - 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. - """ - self._nc = nc - - def set_applications_controller(self, avc): - """ - Bind ApplicationsViewController object to this class. - """ - self._avc = avc - - def set_store(self, store): - """ - Bind AppListStore object to this class. - """ - self._app_store = store - - def setup(self): - self.connect("application-activated", self._on_application_activated) - self._app_store.connect("redraw-request", self._on_redraw_request) - self._app_comment_send_button.connect("clicked", self._on_send_comment) - self._app_comment_send_button.set_sensitive(False) - self._app_comment_text_buffer.connect( - "changed", self._on_comment_buffer_changed) - self._stars.connect("changed", self._on_stars_clicked) - - def _on_comment_buffer_changed(self, widget): - """ - Our comment text is changed, decide if to activate the Send button. - """ - count = self._app_comment_text_buffer.get_char_count() - found = count != 0 - self._app_comment_send_button.set_sensitive(found) - - def _on_application_activated(self, avc, app): - """ - Event received from Gtk widgets requesting us to load package - information. Once we're done loading the shit, we just emit - 'application-show' and let others do the UI switch. - """ - self._last_app = app - task = ParallelTask(self.__application_activate, app) - task.name = "ApplicationActivate" - task.daemon = True - task.start() - - def _on_redraw_request(self, widget, pkg_match): - """ - Redraw request received from AppListStore for given package match. - We are required to update rating, number of downloads, icon. - """ - if self._last_app is None: - return - if pkg_match == self._last_app.get_details().pkg: - stats = self._app_store.get_review_stats(pkg_match) - icon = self._app_store.get_icon(pkg_match) - self._setup_application_stats(stats, icon) - if self._app_store is not None: - self._app_store.emit("redraw-request", self._app_store) - - def _on_stars_clicked(self, widget, app=None): - """ - Stars clicked, user wants to vote. - """ - if app is None: - app = self._last_app - if app is None: - # wtf - return - - def _sender(app, vote): - if not app.is_webservice_available(): - GLib.idle_add(self._notify_webservice_na, app, - self.VOTE_NOTIFICATION_CONTEXT_ID) - return - ws_user = app.get_webservice_username() - if ws_user is not None: - GLib.idle_add(self._notify_vote_submit, app, ws_user, vote) - else: - GLib.idle_add(self._notify_login_request, app, vote, - self._on_stars_login_success, - self._on_stars_login_failed, - self.VOTE_NOTIFICATION_CONTEXT_ID) - - vote = int(self._stars.get_rating()) # is float - task = ParallelTask(_sender, app, vote) - task.name = "AppViewSendVote" - task.start() - - def _on_stars_login_success(self, widget, username, app): - """ - Notify user that we successfully logged in! - """ - box = NotificationBox( - _("Logged in as %s! How about your vote?") \ - % (escape_markup(username),), - message_type=Gtk.MessageType.INFO, - context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) - - def _send_vote(widget): - self._on_stars_clicked(self._stars, app=app) - box.add_button(_("_Vote now"), _send_vote) - - box.add_destroy_button(_("_Later")) - self._nc.append(box) - - def _on_stars_login_failed(self, widget, app): - """ - Entropy Web Services Login failed message. - """ - box = NotificationBox( - _("Login failed. Your vote hasn't been added"), - message_type=Gtk.MessageType.ERROR, - context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) - box.add_destroy_button(_("_Ok, thanks")) - self._nc.append(box) - - def _notify_vote_submit(self, app, username, vote): - """ - Notify User about Comment submission with current credentials. - """ - box = NotificationBox( - _("Rate %s as %s, with %d stars?") \ - % (app.name, escape_markup(username), - vote,), - message_type=Gtk.MessageType.INFO, - context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) - - def _vote_submit(widget): - self._vote_submit(app, username, vote) - box.add_button(_("_Ok, cool!"), _vote_submit) - - def _send_vote(): - self._on_stars_clicked(self._stars, app=app) - def _logout_webservice(widget): - self._logout_webservice(app, _send_vote) - box.add_button(_("_No, logout!"), _logout_webservice) - - box.add_destroy_button(_("_Later")) - self._nc.append(box) - - def _vote_submit(self, app, username, vote): - """ - Do the actual vote submit. - """ - task = ParallelTask( - self._vote_submit_thread_body, - app, username, vote) - task.name = "VoteSubmitThreadBody" - task.daemon = True - task.start() - - def _vote_submit_thread_body(self, app, username, vote): - """ - Called by _vote_submit(), does the actualy submit. - """ - repository_id = app.get_details().channelname - webserv = self._entropy_ws.get(repository_id) - if webserv is None: - # impossible! - return - - key = app.get_details().pkgkey - - err_msg = None - try: - voted = webserv.add_vote( - key, vote) - except WebService.WebServiceException as err: - voted = False - err_msg = str(err) - - def _submit_success(): - nbox = NotificationBox( - _("Your vote has been added!"), - message_type=Gtk.MessageType.INFO, - context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) - nbox.add_destroy_button(_("Ok, great!")) - self._nc.append(nbox, timeout=10) - self._on_redraw_request(None, app.get_details().pkg) - - def _submit_fail(err_msg): - if err_msg is None: - box = NotificationBox( - _("You already voted this Application"), - message_type=Gtk.MessageType.ERROR, - context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) - else: - box = NotificationBox( - _("Vote error: %s") % (err_msg,), - message_type=Gtk.MessageType.ERROR, - context_id=self.VOTE_NOTIFICATION_CONTEXT_ID) - box.add_destroy_button(_("Ok, thanks")) - self._nc.append(box) - - if voted: - GLib.idle_add(_submit_success) - else: - GLib.idle_add(_submit_fail, err_msg) - - def __application_activate(self, app): - """ - Collect data from app, then call the UI setup in the main loop. - """ - details = app.get_details() - metadata = {} - metadata['markup'] = app.get_extended_markup() - metadata['info'] = app.get_info_markup() - metadata['download_size'] = details.downsize - metadata['stats'] = app.get_review_stats() - metadata['homepage'] = details.website - metadata['date'] = details.date - # using app store here because we cache the icon pixbuf - metadata['icon'] = self._app_store.get_icon(details.pkg) - metadata['is_installed'] = app.is_installed() - metadata['is_updatable'] = app.is_updatable() - GLib.idle_add(self._setup_application_info, app, metadata) - - def hide(self): - """ - This method shall be called when the Controller widgets are - going to hide. - """ - self._last_app = None - for child in self._app_my_comments_box.get_children(): - child.destroy() - for child in self._app_images_box.get_children(): - child.destroy() - - self.emit("application-hide", self) - - def _on_send_comment(self, widget, app=None): - """ - Send comment to Web Service. - """ - if app is None: - app = self._last_app - if app is None: - # we're hiding - return - - text = self._app_comment_text_buffer.get_text( - self._app_comment_text_buffer.get_start_iter(), - self._app_comment_text_buffer.get_end_iter(), - False) - if not text.strip(): - return - # make it utf-8 - text = const_convert_to_unicode(text, enctype=etpConst['conf_encoding']) - - def _sender(app, text): - if not app.is_webservice_available(): - GLib.idle_add(self._notify_webservice_na, app, - self.COMMENT_NOTIFICATION_CONTEXT_ID) - return - ws_user = app.get_webservice_username() - if ws_user is not None: - GLib.idle_add(self._notify_comment_submit, app, ws_user, text) - else: - GLib.idle_add(self._notify_login_request, app, text, - self._on_comment_login_success, - self._on_comment_login_failed, - self.COMMENT_NOTIFICATION_CONTEXT_ID) - - task = ParallelTask(_sender, app, text) - task.name = "AppViewSendComment" - task.start() - - def _notify_webservice_na(self, app, context_id): - """ - Notify Web Service unavailability for given Application object. - """ - box = NotificationBox( - "%s: %s" % ( - _("Entropy Web Services not available for repository"), - app.get_details().channelname), - message_type=Gtk.MessageType.ERROR, - context_id=context_id) - box.add_destroy_button(_("Ok, thanks")) - self._nc.append(box) - - def _notify_comment_submit(self, app, username, text): - """ - Notify User about Comment submission with current credentials. - """ - box = NotificationBox( - _("You are about to add a comment as %s.") \ - % (escape_markup(username),), - message_type=Gtk.MessageType.INFO, - context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) - - def _comment_submit(widget): - 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): - self._logout_webservice(app, _send_comment) - box.add_button(_("_No, logout!"), _logout_webservice) - - box.add_destroy_button(_("_Later")) - self._nc.append(box) - - def _comment_submit(self, app, username, text): - """ - Actual Comment submit to Web Service. - Here we arrive from the MainThread. - """ - task = ParallelTask( - self._comment_submit_thread_body, - app, username, text) - task.name = "CommentSubmitThreadBody" - task.daemon = True - task.start() - - def _comment_submit_thread_body(self, app, username, text): - """ - Called by _comment_submit(), does the actualy submit. - """ - repository_id = app.get_details().channelname - webserv = self._entropy_ws.get(repository_id) - if webserv is None: - # impossible! - return - - key = app.get_details().pkgkey - doc_factory = webserv.document_factory() - doc = doc_factory.comment( - username, text, "", "") - - err_msg = None - try: - new_doc = webserv.add_document(key, doc) - except WebService.WebServiceException as err: - new_doc = None - err_msg = str(err) - - def _submit_success(doc): - box = CommentBox(self._nc, self._avc, webserv, doc, is_last=True) - box.connect("destroy", self._on_comment_box_destroy) - - self.__clean_my_non_comment_boxes() - box.render() - self._app_my_comments_box.pack_start(box, False, False, 2) - box.show() - self._app_my_comments_box.show() - - nbox = NotificationBox( - _("Your comment has been submitted!"), - message_type=Gtk.MessageType.INFO, - context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) - nbox.add_destroy_button(_("Ok, great!")) - self._app_comment_text_buffer.set_text("") - self._nc.append(nbox, timeout=10) - - def _submit_fail(): - box = NotificationBox( - _("Comment submit error: %s") % (err_msg,), - message_type=Gtk.MessageType.ERROR, - context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) - box.add_destroy_button(_("Ok, thanks")) - self._nc.append(box) - - if new_doc is not None: - GLib.idle_add(_submit_success, new_doc) - else: - GLib.idle_add(_submit_fail) - - def _logout_webservice(self, app, reinit_callback): - """ - Execute logout of current credentials from Web Service. - Actually, this means removing the local cookie. - """ - repository_id = app.get_details().channelname - webserv = self._entropy_ws.get(repository_id) - if webserv is not None: - webserv.remove_credentials() - - GLib.idle_add(self._avc.emit, "logged-out") - GLib.idle_add(reinit_callback) - - def _notify_login_request(self, app, text, on_success, on_fail, - context_id): - """ - Notify User that login is required - """ - box = LoginNotificationBox( - self._avc, self._entropy_ws, app, - context_id=context_id) - box.connect("login-success", on_success) - box.connect("login-failed", on_fail) - self._nc.append(box) - - def _on_comment_login_success(self, widget, username, app): - """ - Notify user that we successfully logged in! - """ - box = NotificationBox( - _("Logged in as %s! How about your comment?") \ - % (escape_markup(username),), - message_type=Gtk.MessageType.INFO, - context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) - def _send_comment(widget): - self._on_send_comment(widget, app=app) - box.add_button(_("_Send now"), _send_comment) - box.add_destroy_button(_("_Later")) - self._nc.append(box) - - def _on_comment_login_failed(self, widget, app): - """ - Entropy Web Services Login failed message. - """ - box = NotificationBox( - _("Login failed. Your comment hasn't been added"), - message_type=Gtk.MessageType.ERROR, - context_id=self.COMMENT_NOTIFICATION_CONTEXT_ID) - box.add_destroy_button(_("_Ok, thanks")) - self._nc.append(box) - - def _on_comment_box_destroy(self, widget): - """ - Called when a CommentBox is destroyed. - We need to figure out if there are CommentBoxes left and in case - show the "no comments available" message. - """ - children = self._app_comments_box.get_children() - if not children: - self.__show_no_comments() - - def __show_no_comments(self): - """ - Create "No comments for this Application" message. - """ - label = Gtk.Label() - label.set_markup( - prepare_markup( - _("No comments for this Application, yet!"))) - # place in app_my, this way it will get cleared out - # once a new comment is inserted - self._app_my_comments_box.pack_start(label, False, False, 1) - self._app_my_comments_box.show_all() - - def __clean_non_comment_boxes(self): - """ - Remove children that are not CommentBox objects from - self._app_comments_box - """ - for child in self._app_comments_box.get_children(): - if not isinstance(child, CommentBox): - child.destroy() - - def __clean_my_non_comment_boxes(self): - """ - Remove children that are not CommentBox objects from - self._app_my_comments_box - """ - for child in self._app_my_comments_box.get_children(): - if not isinstance(child, CommentBox): - child.destroy() - - def __clean_non_image_boxes(self): - """ - Remove children that are not ImageBox objects from - self._app_images_box - """ - for child in self._app_images_box.get_children(): - if not isinstance(child, ImageBox): - child.destroy() - - def _append_comments(self, downloader, app, comments, has_more): - """ - Append given Entropy WebService Document objects to - the comment area. - """ - self.__clean_non_comment_boxes() - # make sure we didn't leave stuff here as well - self.__clean_my_non_comment_boxes() - - if not comments: - self.__show_no_comments() - return - - if has_more: - button_box = Gtk.HButtonBox() - button = Gtk.Button() - button.set_label(_("Older comments")) - 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 comments...")) - spinner.set_name("comment-box-spinner") - self._app_comments_box.pack_end(spinner, False, False, 3) - spinner.show() - spinner.start() - downloader.enqueue_download() - button.connect("clicked", _enqueue_download) - - button_box.pack_start(button, False, False, 0) - self._app_comments_box.pack_start(button_box, False, False, 1) - button_box.show_all() - - idx = 0 - length = len(comments) - # can be None - webserv = self._entropy_ws.get(app.get_details().channelname) - for doc in comments: - idx += 1 - box = CommentBox( - self._nc, self._avc, webserv, doc, - is_last=(not has_more and (idx == length))) - box.connect("destroy", self._on_comment_box_destroy) - box.render() - self._app_comments_box.pack_end(box, False, False, 2) - box.show() - - def _append_comments_safe(self, downloader, app, comments, has_more): - """ - Same as _append_comments() but thread-safe. - """ - 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. - """ - self.__clean_non_image_boxes() - - if not images: - label = Gtk.Label() - label.set_markup( - prepare_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() - spinner.start() - 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 _on_app_remove(self, widget, app): - """ - Remove the given Application. - """ - self.emit("application-request-action", - app, AppActions.REMOVE) - - def _on_app_install(self, widget, app): - """ - Install (or reinstall) the given Application. - """ - self.emit("application-request-action", - app, AppActions.INSTALL) - - def _setup_buttons(self, app, is_installed, is_updatable): - """ - Setup Application View Buttons (Install/Remove/Update). - """ - button_area = self._app_button_area - for child in button_area.get_children(): - child.destroy() - - if is_installed: - if is_updatable: - update_button = Gtk.Button() - update_button.set_label( - escape_markup(_("Update"))) - def _on_app_update(widget): - return self._on_app_install(widget, app) - update_button.connect("clicked", _on_app_update) - button_area.pack_start(update_button, False, False, 0) - else: - reinstall_button = Gtk.Button() - reinstall_button.set_label( - escape_markup(_("Reinstall"))) - def _on_app_reinstall(widget): - return self._on_app_install(widget, app) - reinstall_button.connect("clicked", _on_app_reinstall) - button_area.pack_start(reinstall_button, False, False, 0) - - remove_button = Gtk.Button() - remove_button.set_label( - escape_markup(_("Remove"))) - def _on_app_remove(widget): - return self._on_app_remove(widget, app) - remove_button.connect("clicked", _on_app_remove) - button_area.pack_start(remove_button, False, False, 0) - - else: - install_button = Gtk.Button() - install_button.set_label( - escape_markup(_("Install"))) - def _on_app_install(widget): - return self._on_app_install(widget, app) - install_button.connect("clicked", _on_app_install) - button_area.pack_start(install_button, False, False, 0) - - button_area.show_all() - - def _setup_application_stats(self, stats, icon): - """ - Setup widgets related to Application statistics (and icon). - """ - total_downloads = stats.downloads_total - if total_downloads < 0: - down_msg = escape_markup(_("Not available")) - elif not total_downloads: - down_msg = escape_markup(_("Never downloaded")) - else: - down_msg = "%s %s" % ( - stats.downloads_total_markup, - escape_markup(_("downloads")),) - - self._app_downloaded_lbl.set_markup(down_msg) - if icon: - self._image.set_from_pixbuf(icon) - self._stars.set_rating(stats.ratings_average) - self._stars_alignment.show_all() - - def _setup_application_info(self, app, metadata): - """ - Setup the actual UI widgets content and emit 'application-show' - """ - self._app_name_lbl.set_markup(metadata['markup']) - self._app_info_lbl.set_markup(metadata['info']) - - # install/remove/update buttons - self._setup_buttons( - app, metadata['is_installed'], - metadata['is_updatable']) - - # only comments supported, point to the remote - # www service for the rest - self._app_comment_more_label.set_markup( - "%s: %s" % ( - escape_markup(_("Want to add images, etc?")), - escape_markup(build_application_store_url(app, "ugc")), - escape_markup(_("click here!")),)) - - stats = metadata['stats'] - icon = metadata['icon'] - self._setup_application_stats(stats, icon) - - # load application comments asynchronously - # so at the beginning, just place a spinner - spinner = Gtk.Spinner() - spinner.set_size_request(-1, 48) - spinner.set_tooltip_text(escape_markup(_("Loading comments..."))) - spinner.set_name("comment-box-spinner") - for child in self._app_comments_box.get_children(): - child.destroy() - self._app_comments_box.pack_start(spinner, False, False, 0) - spinner.show() - spinner.start() - - 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 MetadataDownloader(GObject.Object): - """ - Automated Application comments downloader. - """ - - 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): - """ - Start downloading comments and send them to callback. - Loop over until we have more of them to download. - """ - self._offset = 0 - self._task.start() - - def _download_callback(self, document_list): - """ - Callback called by download_() once data - is arrived from web service. - document_list can be None! - """ - has_more = 0 - if document_list is not None: - has_more = document_list.has_more() - # stash more data? - if has_more and (document_list is not None): - self._offset += len(document_list) - # download() will be called externally - - if const_debug_enabled(): - const_debug_write( - __name__, - "MetadataDownloader._download_callback: %s, more: %s" % ( - document_list, has_more)) - if document_list is not None: - const_debug_write( - __name__, - "MetadataDownloader._download_callback: " - "total: %s, offset: %s" % ( - document_list.total(), document_list.offset())) - - self._callback(self, self._app, document_list, has_more) - - def reset_offset(self): - """ - Reset Metadata download offset to 0. - """ - self._offset = 0 - - def get_offset(self): - """ - Get current Metadata download offset. - """ - return self._offset - - def enqueue_download(self): - """ - Enqueue a new download, starting from current offset - """ - self._task = ParallelTask(self._download) - self._task.start() - - def _download(self): - """ - Thread body of the initial Metadata downloader. - """ - self._app_downloader(self._download_callback, - offset=self._offset) - - class Rigo(Gtk.Application): class RigoHandler(object):