diff --git a/rigo/data/icons/appview-separator.svg b/rigo/data/icons/appview-separator.svg new file mode 100644 index 000000000..ea1e2cb6e --- /dev/null +++ b/rigo/data/icons/appview-separator.svg @@ -0,0 +1,86 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/rigo/data/icons/available-dropshadow.png b/rigo/data/icons/available-dropshadow.png deleted file mode 100644 index 57cef6bd7..000000000 Binary files a/rigo/data/icons/available-dropshadow.png and /dev/null differ diff --git a/rigo/data/icons/available.png b/rigo/data/icons/available.png deleted file mode 100644 index 1b6d60ecb..000000000 Binary files a/rigo/data/icons/available.png and /dev/null differ diff --git a/rigo/data/icons/comment-separator.svg b/rigo/data/icons/comment-separator.svg new file mode 100644 index 000000000..31d2dbdb4 --- /dev/null +++ b/rigo/data/icons/comment-separator.svg @@ -0,0 +1,66 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/rigo/data/icons/history-dropshadow.png b/rigo/data/icons/history-dropshadow.png deleted file mode 100644 index f3a20bec2..000000000 Binary files a/rigo/data/icons/history-dropshadow.png and /dev/null differ diff --git a/rigo/data/icons/history.png b/rigo/data/icons/history.png deleted file mode 100644 index 51542797e..000000000 Binary files a/rigo/data/icons/history.png and /dev/null differ diff --git a/rigo/data/icons/installed-dropshadow.png b/rigo/data/icons/installed-dropshadow.png deleted file mode 100644 index c4ae78994..000000000 Binary files a/rigo/data/icons/installed-dropshadow.png and /dev/null differ diff --git a/rigo/data/icons/installed.png b/rigo/data/icons/installed.png deleted file mode 100644 index 2f4a77ee0..000000000 Binary files a/rigo/data/icons/installed.png and /dev/null differ diff --git a/rigo/data/icons/pending-dropshadow.png b/rigo/data/icons/pending-dropshadow.png deleted file mode 100644 index 2a9cc5a25..000000000 Binary files a/rigo/data/icons/pending-dropshadow.png and /dev/null differ diff --git a/rigo/data/icons/pending.png b/rigo/data/icons/pending.png deleted file mode 100644 index ad9b3be56..000000000 Binary files a/rigo/data/icons/pending.png and /dev/null differ diff --git a/rigo/data/ui/gtk3/css/rigo.css b/rigo/data/ui/gtk3/css/rigo.css index 826061cc6..f3f134003 100644 --- a/rigo/data/ui/gtk3/css/rigo.css +++ b/rigo/data/ui/gtk3/css/rigo.css @@ -74,22 +74,44 @@ GtkViewport { padding: 0; } + +/* Rigo specific part */ + +/* our infobars have to be dark */ +GtkInfoBar.info { + background-color: #1c1c1c; /* gray */ +} +GtkInfoBar.error { + background-color: #770000; /* dark red */ +} +GtkInfoBar.warning { + background-color: #613056; /* purple */ +} +GtkInfoBar #message-area GtkLabel { + color: white; +} + +GtkInfoBar #message-area-error GtkLabel { + color: yellow; +} + #rigoWindow { background-color: white; color: black; } -/* our infobars have to be dark */ -GtkInfoBar.info { - background-color: #1c1c1c; -} -GtkInfoBar.error { - background-color: #770000; -} -GtkInfoBar.warning { - background-color: #613056; -} -GtkInfoBar #notificationMessage { - color: white; +#rigoTextView { + color: black; + background-color: #ebddff; } +/* Application View Comment Box */ +#commentBoxTitle { + color: #613056; /* purple */ +} +#commentBoxComment { + color: #345f14; +} +#commentBoxAuthor { + color: brown; +} \ No newline at end of file diff --git a/rigo/data/ui/gtk3/rigo.ui b/rigo/data/ui/gtk3/rigo.ui index 132d63c44..4c05d5571 100644 --- a/rigo/data/ui/gtk3/rigo.ui +++ b/rigo/data/ui/gtk3/rigo.ui @@ -2,6 +2,17 @@ + + True + False + mail-send-receive + + + True + False + mail-send-receive + + True False @@ -23,6 +34,7 @@ True False + 1 @@ -99,72 +111,160 @@ - - False + + True + never + automatic - + True False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - + True False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - + True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - gtk-missing-image - 6 + + + True + False + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + + + True + False + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + gtk-missing-image + 6 + + + False + False + 0 + + + + + False + False + 8 + 0 + + + + + True + False + + + True + False + 0 + 0 + Application markup + True + True + + + False + False + 0 + + + + + True + True + 1 + + + + + True + False + + + True + False + + + + + + False + False + 0 + + + + + True + False + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + 1 + Downloaded times + True + right + True + + + False + False + 1 + + + + + False + False + 5 + 2 + + False False + 5 0 - - - False - False - 8 - 0 - - - - - True - False - + True False 0 0 - Application markup + 5 + Information label True + True False False - 0 + 1 - - - True - True - 1 - - - - - True - False - + + True + False + 64 + appview-separator + + + False + False + 2 + + + + True False @@ -174,68 +274,105 @@ False False - 0 + 3 - + True False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 1 - Downloaded times - True - True + + + True + False + <small><i>Comment this <b>Application</b></i></small> + True + True + + + True + True + 0 + + + + + True + False + 0.30000001192092896 + 0.30000001192092896 + 5 + 5 + + + 300 + 64 + True + True + word + appViewCommentTextBuffer + False + + + + + True + True + 1 + + + + + True + False + + + _Send + True + True + True + False + appViewCommentSendImage1 + True + + + True + True + 0 + + + + + True + True + 4 + 2 + + + + + True + True + 6 + 4 + + + + + True + False + 64 + appview-separator False False - 1 - - - - - True - False - 1 - Download size - - - False - False - 2 + 5 - - False - False - 5 - 2 - - - False - False - 10 - 0 - - - - - True - False - 0 - 0 - Information label - True - - - False - False - 1 - diff --git a/rigo/rigo/models/application.py b/rigo/rigo/models/application.py index 447ec2e97..d0e2ceda4 100644 --- a/rigo/rigo/models/application.py +++ b/rigo/rigo/models/application.py @@ -27,7 +27,7 @@ from threading import Semaphore, Lock from gi.repository import GObject from entropy.const import const_debug_write, const_debug_enabled, \ - const_convert_to_unicode + const_convert_to_unicode, etpConst from entropy.i18n import _ from entropy.misc import ParallelTask from entropy.services.client import WebService @@ -168,6 +168,7 @@ class ApplicationMetadata(object): ws_map = {} for item in local_queue: webserv, key, repo_id, cb, ts = item + obj = pkg_key_map.setdefault((key, repo_id), []) obj.append((cb, ts)) @@ -181,31 +182,37 @@ class ApplicationMetadata(object): webserv = ws_map[repo_id] request_map = { - "vote": webserv.get_votes, - "down": webserv.get_downloads, - "icon": webserv.get_icons, + "vote": (webserv.get_votes, {}), + "down": (webserv.get_downloads, {}), + # for these two, service_cache is broken + # and actually useless. + "icon": (webserv.get_icons, {}), + "comment": (webserv.get_comments, {}), } - # FIXME: lxnay, who validates this cache? - for request in request_list: outcome = {} uncached_keys = [] for key in keys: + request_func, req_kwargs = request_map[request] + try: - outcome[(key, repo_id)] = request_map[request]( - [key], cache=True, cached=True)[key] + outcome[(key, repo_id)] = request_func( + [key], cache=True, cached=True, + **req_kwargs)[key] except cache_miss: uncached_keys.append(key) for key in uncached_keys: if discard_signal.get(): break + + request_func, req_kwargs = request_map[request] try: # FIXME, lxnay: work more instances in parallel? - outcome[(key, repo_id)] = request_map[request]( - [key], cache = True)[key] + outcome[(key, repo_id)] = request_func( + [key], cache = True, **req_kwargs)[key] except ws_exception as wse: const_debug_write( __name__, @@ -280,7 +287,7 @@ class ApplicationMetadata(object): @staticmethod def _enqueue_rating(webservice, package_key, repository_id, callback): """ - Enqueue the retriveal of the Rating for package key in given repository. + Enqueue the retrieval of the Rating for package key in given repository. Once the data is ready, callback() will be called passing the payload. This method is asynchronous and returns as soon as possible. @@ -335,8 +342,8 @@ class ApplicationMetadata(object): Return the Rating (stars) for given package key, if it's available in local cache. At the same time, if not available and not already enqueued for download, do it, atomically. - Return None if not available, the rating otherwise (tuple composed - by (vote, number_of_downloads)). + Raise WebService.CacheMiss if not available, the rating otherwise + (tuple composed by (vote, number_of_downloads)). """ webserv = entropy_ws.get(repository_id) if webserv is None: @@ -365,6 +372,48 @@ class ApplicationMetadata(object): return vote, down + @staticmethod + def download_rating_async(entropy_ws, package_key, repository_id, + callback): + """ + Asynchronously download updated information regarding the + Rating of given package. + 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((vote, Number_of_downloads)) [one argument, which is a tuple] + 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 = "DownloadRatingAsync::None" + task.daemon = True + task.start() + return None + + def _getter(): + outcome = None + try: + vote = webserv.get_votes( + [package_key], cache=False)[package_key] + down = webserv.get_downloads( + [package_key], cache=False)[package_key] + outcome = (vote, down) + finally: + # ignore exceptions, if any, and always + # call callback. + callback(outcome) + + task = ParallelTask(_getter) + task.name = "DownloadRatingAsync::Getter" + task.daemon = True + task.start() + @staticmethod def lazy_get_icon(entropy_ws, package_key, repository_id, callback=None): @@ -375,6 +424,9 @@ class ApplicationMetadata(object): Return None if not available, or DocumentList (see Entropy Services API) otherwise. DocumentList contains a list of Document objects, and calling Document.local_document() would give you the image path. + If cache is not available however, WebService.CacheMiss is raised and + an asynchronous request shall be submitted to the remove web service, + once data is ready, callback will be called. """ webserv = entropy_ws.get(repository_id) if webserv is None: @@ -430,6 +482,100 @@ class ApplicationMetadata(object): return icon + @staticmethod + def download_icon_async(entropy_ws, package_key, repository_id, + callback): + """ + Asynchronously download updated information regarding the + Icon of given package. + 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(Icon object) + 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 = "DownloadIconAsync::None" + task.daemon = True + task.start() + return None + + def _pick_icon(icons): + return icons[0] + + def _getter(): + outcome = None + try: + icons = webserv.get_icons( + [package_key], cache=False)[package_key] + + # pick the first icon as document icon + icon = _pick_icon(icons) + # check if we have the file on-disk, otherwise + # spawn the fetch in parallel. + icon_path = icon.local_document() + if not os.path.isfile(icon_path): + local_path = ApplicationMetadata._download_document( + webserv, icon) + if local_path: + outcome = icon + finally: + # ignore exceptions, if any, and always + # call callback. + callback(outcome) + + task = ParallelTask(_getter) + task.name = "DownloadIconAsync::Getter" + task.daemon = True + task.start() + + @staticmethod + def download_comments_async(entropy_ws, package_key, repository_id, + offset, callback): + """ + Asynchronously download updated information regarding the + comments of given package. + 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 = "DownloadCommentsAsync::None" + task.daemon = True + task.start() + return None + + def _getter(): + outcome = None + try: + outcome = webserv.get_comments( + [package_key], cache=False, + offset=offset)[package_key] + finally: + # ignore exceptions, if any, and always + # call callback. + callback(outcome) + + task = ParallelTask(_getter) + task.name = "DownloadCommentsAsync::Getter" + task.daemon = True + task.start() + + class SignalBoolean(object): def __init__(self, val): @@ -454,7 +600,7 @@ class ApplicationMetadata(object): _RATING_LOCK = Lock() _RATING_IN_FLIGHT = set() - # Application Documents logic + # Application Icons logic _ICON_QUEUE = deque() def _icon_thread_body_wrapper(): return ApplicationMetadata._icon_thread_body() @@ -622,6 +768,63 @@ class Application(object): ) return text + def get_info_markup(self): + """ + Get Application info markup text. + """ + lic_url = "%s/license/" % (etpConst['packages_website_url'],) + repo = self._entropy.open_repository(self._repo_id) + + licenses = repo.retrieveLicense(self._pkg_id) + if licenses: + licenses_txt = "%s: " % (_("License"),) + licenses_txt += ", ".join(sorted([ + "%s" % (lic_url, x, x) \ + for x in licenses.split()])) + else: + licenses_txt = "" + + required_space = repo.retrieveOnDiskSize(self._pkg_id) + if required_space is None: + required_space = 0 + required_space_txt = "%s: %s" % ( + _("Required space"), + entropy.tools.bytes_into_human(required_space),) + + down_size = repo.retrieveSize(self._pkg_id) + if down_size is None: + down_size = 0 + down_size_txt = "%s: %s" % ( + _("Download size"), + entropy.tools.bytes_into_human(down_size),) + + digest = repo.retrieveDigest(self._pkg_id) + if digest is None: + digest = _("N/A") + digest_txt = "%s: %s" % (_("Checksum"), digest) + + uses = repo.retrieveUseflags(self._pkg_id) + uses = sorted(uses) + use_list = [] + use_url = "%s/useflag/" % (etpConst['packages_website_url'],) + for use in uses: + txt = "%s" % (use_url, use, use) + use_list.append(txt) + use_txt = "%s: " % (_("USE flags"),) + if use_list: + use_txt += " ".join(use_list) + else: + use_txt += _("No use flags") + + text = "%s\n%s\n%s\n%s\n%s" % ( + down_size_txt, + required_space_txt, + licenses_txt, + digest_txt, + use_txt, + ) + return text + def get_review_stats(self): """ Return ReviewStats object containing user review @@ -655,6 +858,82 @@ class Application(object): stat.downloads_total = down return stat + def get_icon(self): + """ + Return Application Icon image Entropy Document object. + In case of missing icon, None is returned. + The actual outcome of this property is a tuple, composed + by the Document object (or None) and cache hit information + (True if got from local cache, False if not in local cache) + """ + repo = self._entropy.open_repository(self._repo_id) + key_slot = repo.retrieveKeySlot(self._pkg_id) + if key_slot is None: + return None, False + + key, slot = key_slot + cache_hit = True + try: + icon = ApplicationMetadata.lazy_get_icon( + self._entropy_ws, key, self._repo_id, + callback=self._redraw_callback) + except WebService.CacheMiss: + cache_hit = False + icon = None + + if const_debug_enabled(): + const_debug_write(__name__, + "Application{%s}.get_icon: icon: %s, cache hit: %s" % ( + self._pkg_match, + icon, cache_hit)) + + return icon, cache_hit + + def download_comments(self, callback, offset=0): + """ + Return Application Comments 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 = "DownloadCommentsNoneCallback" + task.daemon = True + task.start() + return + + key, slot = key_slot + ApplicationMetadata.download_comments_async( + self._entropy_ws, key, self._repo_id, + offset, callback) + + if const_debug_enabled(): + const_debug_write(__name__, + "Application{%s}.download_comments called" % ( + self._pkg_match,)) + + def is_webservice_available(self): + """ + Return whether the Entropy Web Service is available + for this Application. + """ + webserv = self._entropy_ws.get(self._repo_id) + if webserv: + return True + return False + + def get_webservice_username(self): + """ + Return the currently logged Entropy Web Services + username, or None, if any. + """ + webserv = self._entropy_ws.get(self._repo_id) + if webserv is None: + return None + return webserv.get_credentials() + # get a AppDetails object for this Applications def get_details(self): """ @@ -711,38 +990,6 @@ class AppDetails(object): def error(self): return _("Application not found") - @property - def icon(self): - """ - Return Application Icon image Entropy Document object. - In case of missing icon, None is returned. - The actual outcome of this property is a tuple, composed - by the Document object (or None) and cache hit information - (True if got from local cache, False if not in local cache) - """ - repo = self._entropy.open_repository(self._repo_id) - key_slot = repo.retrieveKeySlot(self._pkg_id) - if key_slot is None: - return None, False - - key, slot = key_slot - cache_hit = True - try: - icon = ApplicationMetadata.lazy_get_icon( - self._entropy_ws, key, self._repo_id, - callback=self._redraw_callback) - except WebService.CacheMiss: - cache_hit = False - icon = None - - if const_debug_enabled(): - const_debug_write(__name__, - "AppDetails{%s}.icon: icon: %s, cache hit: %s" % ( - self._pkg_match, - icon, cache_hit)) - - return icon, cache_hit - @property def installation_date(self): """ @@ -817,9 +1064,19 @@ class AppDetails(object): @property def pkgname(self): """ - Return un-mangled package name belonging to this Application. + Return unmangled package name belonging to this Application. """ - return self.name + repo = self._entropy.open_repository(self._repo_id) + return repo.retrieveName(self._pkg_id) + + @property + def pkgkey(self): + """ + Return unmangled package key name belonging to this Application. + """ + repo = self._entropy.open_repository(self._repo_id) + key, slot = repo.retrieveKeySlot(self._pkg_id) + return key @property def signing_key_id(self): @@ -844,19 +1101,3 @@ class AppDetails(object): repo = self._entropy.open_repository(self._repo_id) return repo.retrieveHomepage(self._pkg_id) - def __str__(self): - details = [] - details.append("* AppDetails") - details.append(" name: %s" % self.name) - details.append(" display_name: %s" % self.display_name) - details.append(" pkg: %s" % self.pkg) - details.append(" pkgname: %s" % self.pkgname) - details.append(" channelname: %s" % self.channelname) - details.append(" description: %s" % self.description) - details.append(" error: %s" % self.error) - details.append(" icon: %s" % self.icon) - details.append(" installation_date: %s" % self.installation_date) - details.append(" licenses: %s" % self.licenses) - details.append(" version: %s" % self.version) - details.append(" website: %s" % self.website) - return '\n'.join(details) diff --git a/rigo/rigo/ui/gtk3/models/appliststore.py b/rigo/rigo/ui/gtk3/models/appliststore.py index 687123408..17c6c16e0 100644 --- a/rigo/rigo/ui/gtk3/models/appliststore.py +++ b/rigo/rigo/ui/gtk3/models/appliststore.py @@ -74,11 +74,16 @@ class AppListStore(Gtk.ListStore): def _ui_redraw_callback(*args): if const_debug_enabled(): const_debug_write(__name__, - "_ui_redraw_callback()") + "_ui_redraw_callback(), %s" % (args,)) self.emit("redraw-request", pkg_match) app = Application(self._entropy, self._entropy_ws, pkg_match, redraw_callback=_ui_redraw_callback) - icon, cache_hit = app.get_details().icon + icon, cache_hit = app.get_icon() + if const_debug_enabled(): + const_debug_write(__name__, + "get_icon({%s, %s}) = %s, hit: %s" % ( + (pkg_match, app.name, icon, cache_hit,))) + if icon is None: if cache_hit: # this means that there is no icon for package diff --git a/rigo/rigo/ui/gtk3/widgets/comments.py b/rigo/rigo/ui/gtk3/widgets/comments.py new file mode 100644 index 000000000..29b59533c --- /dev/null +++ b/rigo/rigo/ui/gtk3/widgets/comments.py @@ -0,0 +1,84 @@ +# 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, Pango, GObject + +from entropy.client.services.interfaces import Document, DocumentFactory + +import entropy.tools + +class CommentBox(Gtk.VBox): + + def __init__(self, comment, is_last=False): + Gtk.VBox.__init__(self) + self._comment = comment + self.set_name("commentBox") + self.set_spacing(2) + self._is_last = is_last + + def render(self): + # title, keywords, ddata, document_id + title = self._comment['title'].strip() + + if title: + title_id = Document.DOCUMENT_TITLE_ID + label = Gtk.Label() + label.set_markup("" + self._comment[title_id] + "") + label.set_name("commentBoxTitle") + 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() + self.pack_start(label, False, False, 0) + + data_id = Document.DOCUMENT_DATA_ID + label = Gtk.Label() + label.set_markup("" + self._comment[data_id] + "") + label.set_name("commentBoxComment") + 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() + self.pack_start(label, False, False, 0) + + 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._comment[ts_id]) + time_str = GObject.markup_escape_text(time_str) + label.set_markup( + "" + time_str \ + + ", %s" % (self._comment[user_id],) \ + + "") + label.set_line_wrap(True) + label.set_line_wrap_mode(Pango.WrapMode.WORD) + label.set_alignment(0.98, 0.0) + label.set_selectable(True) + label.set_name("commentBoxAuthor") + label.show() + self.pack_start(label, False, False, 0) + + if not self._is_last: + image = Gtk.Image.new_from_icon_name("comment-separator", + Gtk.IconSize.BUTTON) + image.set_pixel_size(50) + self.pack_start(image, False, False, 0) + image.show() diff --git a/rigo/rigo/ui/gtk3/widgets/notifications.py b/rigo/rigo/ui/gtk3/widgets/notifications.py index fcbb2820b..d10a2ad33 100644 --- a/rigo/rigo/ui/gtk3/widgets/notifications.py +++ b/rigo/rigo/ui/gtk3/widgets/notifications.py @@ -1,7 +1,9 @@ -from gi.repository import Gtk, GObject +from gi.repository import Gtk, GLib, GObject from entropy.i18n import _ +from entropy.services.client import WebService +from entropy.misc import ParallelTask class NotificationBox(Gtk.HBox): @@ -11,9 +13,12 @@ class NotificationBox(Gtk.HBox): Rigo notification area. """ - def __init__(self, message, message_type=None, tooltip=None): + def __init__(self, message, message_widget=None, + message_type=None, tooltip=None): Gtk.HBox.__init__(self) self._message = message + # if not None, it will replace Gtk.Label(self._message) + self._message_widget = message_widget self._buttons = [] self._type = message_type if self._type is None: @@ -31,6 +36,14 @@ class NotificationBox(Gtk.HBox): self._buttons.append(button) return button + def add_destroy_button(self, text): + """ + Add button that destroys the whole Notification object. + """ + def _destroy(*args): + self.destroy() + self.add_button(text, _destroy) + def render(self): """ Render the Notification box filling in the container. @@ -42,25 +55,30 @@ class NotificationBox(Gtk.HBox): content_area = bar.get_content_area() hbox = Gtk.HBox() - label = Gtk.Label() - label.set_markup(self._message) - # make it css-able - label.set_name("notificationMessage") - label.set_property("expand", True) - label.set_alignment(0.02, 0.50) - hbox.pack_start(label, True, True, 0) - label.show() + message_hbox = Gtk.HBox() + message_hbox.set_name("message-area") + if self._message_widget is None: + label = Gtk.Label() + label.set_markup(self._message) + # make it css-able + label.set_property("expand", True) + label.set_alignment(0.02, 0.50) + message_hbox.pack_start(label, True, True, 0) + else: + message_hbox.pack_start(self._message_widget, True, True, 0) + hbox.pack_start(message_hbox, True, True, 0) + + button_hbox = Gtk.HBox() + button_hbox.set_name("button-area") for button in self._buttons: - hbox.pack_start(button, False, False, 3) - button.show() + button_hbox.pack_start(button, False, False, 3) + hbox.pack_start(button_hbox, False, False, 2) content_area.set_property("expand", False) content_area.add(hbox) - content_area.show() - hbox.show() - bar.show() + bar.show_all() bar.get_action_area().hide() self.pack_start(bar, True, True, 0) @@ -103,9 +121,7 @@ class UpdatesNotificationBox(NotificationBox): message_type=Gtk.MessageType.WARNING) self.add_button(_("_Update System"), self._update) self.add_button(_("_Show"), self._show) - def _destroy(*args): - self.destroy() - self.add_button(_("_Ignore"), _destroy) + self.add_destroy_button(_("_Ignore")) def _update(self, button): """ @@ -140,12 +156,124 @@ class RepositoriesUpdateNotificationBox(NotificationBox): tooltip=_("I dunno dude, I'd say Yes"), message_type=Gtk.MessageType.ERROR) self.add_button(_("_Yes, why not?"), self._update) - def _destroy(*args): - self.destroy() - self.add_button(_("_No, thanks"), _destroy) + self.add_destroy_button(_("_No, thanks")) def _update(self, button): """ Update button callback from the updates notification box. """ self.emit("update-request") + + +class LoginNotificationBox(NotificationBox): + + """ + NotificationBox asking user to login to Entropy Web Service. + """ + + __gsignals__ = { + # Emitted when login is successful + "login-success" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT,), + ), + # Emitted when login fails, not going + # to retry anymore + "login-failed" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + } + + def __init__(self, entropy_ws, app): + self._entropy_ws = entropy_ws + self._app = app + self._repository_id = app.get_details().channelname + + NotificationBox.__init__(self, None, + message_widget=self._make_login_box(), + tooltip=_("You need to login to Entropy Web Services"), + message_type=Gtk.MessageType.WARNING) + + self.add_button(_("_Login"), self._login) + + def _destroy(*args): + self.emit("login-failed", self._app) + self.destroy() + self.add_button(_("_Cancel"), _destroy) + + def _make_login_box(self): + + vbox = Gtk.VBox() + + hbox = Gtk.HBox() + username_label = Gtk.Label() + username_label.set_markup(_("Username:")) + hbox.pack_start(username_label, False, False, 2) + + self._username_entry = Gtk.Entry() + hbox.pack_start(self._username_entry, False, False, 0) + + password_label = Gtk.Label() + password_label.set_markup(_(", password:")) + hbox.pack_start(password_label, False, False, 2) + + self._password_entry = Gtk.Entry() + self._password_entry.set_visibility(False) + hbox.pack_start(self._password_entry, False, False, 0) + + hbox.set_property("expand", True) + + vbox.pack_start(hbox, False, False, 0) + self._login_message = Gtk.Label() + self._login_message.set_no_show_all(True) + self._login_message.set_alignment(0.0, 1.0) + self._login_message.set_padding(-1, 8) + self._login_message.set_name("message-area-error") + vbox.pack_start(self._login_message, False, False, 0) + + return vbox + + def _login_thread_body(self, username, password): + """ + Execute the actual login procedure. + """ + webserv = self._entropy_ws.get(self._repository_id) + if webserv is None: + # can't be, if we're here, this is already not None + return + + def _login_error(): + self._login_message.show() + self._login_message.set_markup( + _("Login error!")) + + webserv.add_credentials(username, password) + try: + webserv.validate_credentials() + except WebService.MethodNotAvailable: + GLib.idle_add(_login_error) + return + except WebService.AuthenticationFailed: + webserv.remove_credentials() + GLib.idle_add(_login_error) + return + + def _emit_success(): + self.emit("login-success", username, self._app) + GLib.idle_add(self.destroy) + GLib.idle_add(_emit_success) + + def _login(self, button): + """ + Try to login to Entropy Web Services. + """ + username = self._username_entry.get_text() + password = self._password_entry.get_text() + + task = ParallelTask( + self._login_thread_body, username, password) + task.name = "LoginNotificationThreadBody" + task.daemon = True + task.start() + diff --git a/rigo/rigo/utils.py b/rigo/rigo/utils.py new file mode 100644 index 000000000..8b4a42154 --- /dev/null +++ b/rigo/rigo/utils.py @@ -0,0 +1,35 @@ + +import os + +from entropy.const import etpConst +from entropy.core.settings.base import SystemSettings + +def build_application_store_url(app, sub_page): + """ + take rigo.models.application.Application object + and build up HTTP Entropy Application Store URL + pointing to exact Application page. + sub_page is used to load a specific part of the page, + for example "ugc" can be passed to load URL/ugc page. + """ + settings = SystemSettings() + details = app.get_details() + pkg_id, pkg_repo = details.pkg + branch = settings['repositories']['branch'] + product = settings['repositories']['product'] + url = "%s/show/%s,%s,%s,%s,%s,%s/%s" % ( + etpConst['packages_website_url'], + details.pkgname, + pkg_id, + pkg_repo, + etpConst['currentarch'], + branch, + product, + sub_page) + return url + +def build_register_url(): + """ + Build User Account Registration Form URL. + """ + return os.path.join(etpConst['distro_website_url'], "register") diff --git a/rigo/rigo_app.py b/rigo/rigo_app.py index 09382818f..efbcc3070 100644 --- a/rigo/rigo_app.py +++ b/rigo/rigo_app.py @@ -21,15 +21,20 @@ from rigo.entropyapi import EntropyWebService from rigo.models.application import Application, ApplicationMetadata from rigo.ui.gtk3.widgets.apptreeview import AppTreeView from rigo.ui.gtk3.widgets.notifications import NotificationBox, \ - RepositoriesUpdateNotificationBox, UpdatesNotificationBox + RepositoriesUpdateNotificationBox, UpdatesNotificationBox, \ + LoginNotificationBox 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.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 -from entropy.const import etpUi, const_debug_write, const_debug_enabled +from entropy.const import etpUi, const_debug_write, const_debug_enabled, \ + const_convert_to_unicode from entropy.client.interfaces import Client from entropy.client.interfaces.repository import Repository +from entropy.services.client import WebService from entropy.misc import TimeScheduled, ParallelTask from entropy.i18n import _, ngettext @@ -151,7 +156,10 @@ class ApplicationsViewController(GObject.Object): class NotificationViewController(GObject.Object): """ - Notification area widget controller code. + Notification area widget controller. + This class features the handling of some built-in + Notification objects (like updates and outdated repositories) + but also accepts external NotificationBox instances as well. """ def __init__(self, entropy_client, avc, notification_box): @@ -290,17 +298,22 @@ class ApplicationViewController(GObject.Object): # Double click on application widget "application-activated" : (GObject.SignalFlags.RUN_LAST, None, - (GObject.TYPE_PYOBJECT, ), + (GObject.TYPE_PYOBJECT,), ), # Show Application in the Rigo UI "application-show" : (GObject.SignalFlags.RUN_LAST, None, - (GObject.TYPE_PYOBJECT, ), + (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, ), + (GObject.TYPE_PYOBJECT,), ), # action requested for application "application-request-action" : (GObject.SignalFlags.RUN_LAST, @@ -318,15 +331,22 @@ class ApplicationViewController(GObject.Object): self._entropy = entropy_client self._entropy_ws = entropy_ws self._app_store = None - self._last_pkg_match = None + self._last_app = None + self._nc = None 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_download_lbl = self._builder.get_object( - "appViewDownloadedLabel") self._app_downloaded_lbl = self._builder.get_object( - "appViewDownloadSizeLabel") + "appViewDownloadedLabel") + self._app_comments_box = self._builder.get_object("appViewCommentsVbox") + 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("rigoTextView") + self._app_comment_text_buffer = self._builder.get_object( + "appViewCommentTextBuffer") self._stars_container = self._builder.get_object("appViewStarsSelVbox") self._stars = ReactiveStar() @@ -337,6 +357,11 @@ class ApplicationViewController(GObject.Object): self._stars_container.pack_start(self._stars_alignment, False, False, 0) + def set_notification_controller(self, nc): + """ + Bind NotificationController object to this class. + """ + self._nc = nc def set_store(self, store): """ @@ -347,6 +372,18 @@ class ApplicationViewController(GObject.Object): 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) + + 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): """ @@ -354,7 +391,7 @@ class ApplicationViewController(GObject.Object): information. Once we're done loading the shit, we just emit 'application-show' and let others do the UI switch. """ - self._last_pkg_match = app.get_details().pkg + self._last_app = app task = ParallelTask(self.__application_activate, app) task.name = "ApplicationActivate" task.daemon = True @@ -365,7 +402,9 @@ class ApplicationViewController(GObject.Object): Redraw request received from AppListStore for given package match. We are required to update rating, number of downloads, icon. """ - if pkg_match == self._last_pkg_match: + 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) @@ -377,6 +416,7 @@ class ApplicationViewController(GObject.Object): 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 @@ -385,6 +425,256 @@ class ApplicationViewController(GObject.Object): metadata['icon'] = self._app_store.get_icon(details.pkg) 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 + 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="utf-8") + + # get entropy ws, check if we're logged in + # if not, use LoginNotificationBox and wait on signal + # if yes, use SubmitDocumentNotificationBox and wait on signal + # FIXME, lxnay complete + + def _sender(app, text): + if not app.is_webservice_available(): + GLib.idle_add(self._notify_webservice_na, app) + 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) + + task = ParallelTask(_sender, app, text) + task.name = "AppViewSendComment" + task.start() + + def _notify_webservice_na(self, app): + """ + 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) + 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.") \ + % (GObject.markup_escape_text(username),), + message_type=Gtk.MessageType.INFO) + + def _comment_submit(widget): + box.destroy() + self._comment_submit(app, username, text) + box.add_button(_("_Ok, cool!"), _comment_submit) + + def _logout_webservice(widget): + box.destroy() + self._logout_webservice(app) + box.add_button(_("_No, logout!"), _logout_webservice) + + box.add_destroy_button(_("_Abort")) + 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(doc, is_last=True) + box.render() + self._app_comments_box.pack_start(box, False, False, 2) + box.show() + + nbox = NotificationBox( + _("Your comment has been submitted!"), + message_type=Gtk.MessageType.INFO) + nbox.add_destroy_button(_("Ok, great!")) + self._nc.append(nbox, timeout=10) + + def _submit_fail(): + box = NotificationBox( + _("Comment submit error: %s") % (err_msg,), + message_type=Gtk.MessageType.ERROR) + 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): + """ + 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() + + def _send_comment(): + self._on_send_comment(None, app=app) + GLib.idle_add(_send_comment) + + def _notify_login_request(self, app, text): + """ + Notify User that login is required + """ + box = LoginNotificationBox(self._entropy_ws, app) + box.connect("login-success", self._on_comment_login_success) + box.connect("login-failed", self._on_comment_login_failed) + 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?") \ + % (GObject.markup_escape_text(username),), + message_type=Gtk.MessageType.INFO) + def _send_comment(widget): + box.destroy() + self._on_send_comment(widget, app=app) + box.add_button(_("_Send now"), _send_comment) + box.add_destroy_button(_("_Abort")) + 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) + box.add_destroy_button(_("_Ok, thanks")) + self._nc.append(box) + + def _append_comments(self, downloader, app, comments, has_more): + """ + Append given Entropy WebService Document objects to + the comment area. + """ + more_button_name = "commentBoxMoreButton" + + # remove spinner if there, ugly O(n) + for child in self._app_comments_box.get_children(): + if isinstance(child, Gtk.Spinner): + child.destroy() + if child.get_name() == more_button_name: + child.destroy() + + if not comments: + label = Gtk.Label() + label.set_markup( + _("No comments for this Application, yet!")) + self._app_comments_box.pack_start(label, False, False, 1) + + label = Gtk.Label() + url = build_register_url() + url = GObject.markup_escape_text(url) + reg_msg = _("You need to register here") % ( + url,) + label.set_markup("" + reg_msg + "") + label.show() + self._app_comments_box.pack_start(label, False, False, 1) + return + + idx = 0 + length = len(comments) + for doc in comments: + idx += 1 + box = CommentBox(doc, is_last=(not has_more and (idx == length))) + box.render() + self._app_comments_box.pack_start(box, False, False, 2) + box.show() + + if has_more: + button = Gtk.Button() + button.set_label(_("More comments")) + button.set_name(more_button_name) + button.set_alignment(0.5, 0.5) + def _enqueue_download(widget): + widget.hide() + spinner = Gtk.Spinner() + spinner.set_size_request(-1, 24) + spinner.set_tooltip_text(_("Loading comments...")) + spinner.set_name("commentBoxSpinner") + self._app_comments_box.pack_start(spinner, False, False, 3) + spinner.show() + downloader.enqueue_download() + button.connect("clicked", _enqueue_download) + self._app_comments_box.pack_start(button, False, False, 1) + button.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 _setup_application_stats(self, stats, icon): """ Setup widgets related to Application statistics (and icon). @@ -393,11 +683,12 @@ class ApplicationViewController(GObject.Object): if not total_downloads: down_msg = _("Never downloaded") else: - down_msg = ngettext("%d download", - "%d downloads", + down_msg = ngettext("%d\ndownload", + "%d\ndownloads", total_downloads) down_msg = down_msg % (total_downloads,) - self._app_download_lbl.set_markup(down_msg) + + self._app_downloaded_lbl.set_markup(down_msg) if icon: self._image.set_from_pixbuf(icon) self._stars.set_rating(stats.ratings_average - 1) @@ -407,23 +698,119 @@ class ApplicationViewController(GObject.Object): """ Setup the actual UI widgets content and emit 'application-show' """ - # FIXME, lxnay complete - down_size = entropy.tools.bytes_into_human( - metadata['download_size']) - self._app_downloaded_lbl.set_markup("%s" % (down_size,)) self._app_name_lbl.set_markup(metadata['markup']) + self._app_info_lbl.set_markup(metadata['info']) - # GPG info, required space, use flags + # FIXME, lxnay complete # install/remove/update buttons - # show comments (and allow commenting) - # allow screenshots upload? + # for the rest, point to the remote www service 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(_("Loading comments...")) + spinner.set_name("commentBoxSpinner") + 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.CommentsDownloader( + app, self, self._append_comments_safe) + downloader.start() + self.emit("application-show", app) + class CommentsDownloader(GObject.Object): + """ + Automated Application comments downloader. + """ + + def __init__(self, app, avc, callback): + self._app = app + self._avc = avc + self._offset = 0 + self._callback = callback + self._avc.connect("application-hide", self.stop) + self._task = ParallelTask(self._download) + self._started = False + + def stop(self, *args): + """ + Stop downloading comments, if we're still doing it. + """ + self._started = False + print("Application-hide called, this is", self) + + 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._started = True + self._task.start() + + def _download_callback(self, document_list): + """ + Callback called by download_comments() 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__, + "CommentsDownloader._download_callback: %s, more: %s" % ( + document_list, has_more)) + if document_list is not None: + const_debug_write( + __name__, + "CommentsDownloader._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 Comments download offset to 0. + """ + self._offset = 0 + + def get_offset(self): + """ + Get current Comments 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 Comments downloader. + """ + self._app.download_comments(self._download_callback, + offset=self._offset) + class Rigo(Gtk.Application): @@ -455,7 +842,10 @@ class Rigo(Gtk.Application): self._window.set_name("rigoWindow") self._apps_view = self._builder.get_object("appsViewVbox") self._scrolled_view = self._builder.get_object("appsViewScrolledWindow") - self._app_view = self._builder.get_object("appViewVbox") + self._app_view = self._builder.get_object("appViewScrollWin") + self._app_view.set_name("rigoWindow") + self._app_view_port = self._builder.get_object("appViewVport") + self._app_view_port.set_name("rigoWindow") self._search_entry = self._builder.get_object("searchEntry") self._static_view = self._builder.get_object("staticViewVbox") self._notification = self._builder.get_object("notificationBox") @@ -512,6 +902,7 @@ class Rigo(Gtk.Application): self._nc = NotificationViewController( self._entropy, self._avc, self._notification) + self._app_view_c.set_notification_controller(self._nc) def _on_view_cleared(self, *args): self._change_view_state(Rigo.STATIC_VIEW_STATE) @@ -572,6 +963,7 @@ class Rigo(Gtk.Application): state (or mode). Hiding back application information. """ self._app_view.hide() + self._app_view_c.hide() def _change_view_state(self, state): """