[Rigo] move completed Rigo View Controllers to separate modules
This commit is contained in:
@@ -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 <b>%s</b>! How about your <b>vote</b>?") \
|
||||
% (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 <b>vote</b> 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 <b>%s</b> as <b>%s</b>, with <b>%d</b> 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 <b>Application</b>"),
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
context_id=self.VOTE_NOTIFICATION_CONTEXT_ID)
|
||||
else:
|
||||
box = NotificationBox(
|
||||
_("Vote error: <i>%s</i>") % (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: <b>%s</b>" % (
|
||||
_("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 <b>comment</b> as <b>%s</b>.") \
|
||||
% (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: <i>%s</i>") % (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 <b>%s</b>! How about your <b>comment</b>?") \
|
||||
% (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 <b>comment</b> 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(
|
||||
_("<i>No <b>comments</b> for this Application, yet!</i>")))
|
||||
# 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(
|
||||
_("<i>No <b>images</b> for this Application, yet!</i>")))
|
||||
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 = "<small><b>%s</b> %s</small>" % (
|
||||
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(
|
||||
"<b>%s</b>: <a href=\"%s\">%s</a>" % (
|
||||
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_<something>() 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)
|
||||
@@ -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 <b>%s</b>" % (
|
||||
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 <a href=\"%s\">%s</a>?")) % (
|
||||
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 <a href=""/> 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)
|
||||
+4
-1150
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user