From a7e5f2f3cb81469ead3d4a0d4c3f4dd4f37cdb42 Mon Sep 17 00:00:00 2001 From: Fabio Erculiani Date: Sun, 12 Feb 2012 23:19:09 +0100 Subject: [PATCH] [rigo] integrate package icons retrieval --- rigo/rigo/models/application.py | 419 ++++++++++++++++----- rigo/rigo/ui/gtk3/utils.py | 55 ++- rigo/rigo/ui/gtk3/widgets/buttons.py | 5 +- rigo/rigo/ui/gtk3/widgets/cellrenderers.py | 2 +- rigo/rigo_app.py | 112 ++++-- 5 files changed, 459 insertions(+), 134 deletions(-) diff --git a/rigo/rigo/models/application.py b/rigo/rigo/models/application.py index 42e95bd51..3ca1d1698 100644 --- a/rigo/rigo/models/application.py +++ b/rigo/rigo/models/application.py @@ -30,8 +30,11 @@ from entropy.const import const_debug_write, const_debug_enabled from entropy.i18n import _ from entropy.misc import ParallelTask from entropy.services.client import WebService +from entropy.client.services.interfaces import ClientWebService from rigo.utils import get_entropy_webservice +from rigo.enums import Icons + LOG = logging.getLogger(__name__) @@ -42,14 +45,14 @@ class ReviewStats(object): def __init__(self, app): self.app = app self.ratings_average = None - self.ratings_total = 0 + self.downloads_total = 0 self.rating_spread = [0,0,0,0,0] self.dampened_rating = 3.00 def __repr__(self): - return ("" % - (self.app, self.ratings_average, self.ratings_total, + (self.app, self.ratings_average, self.downloads_total, self.rating_spread, self.dampened_rating)) class CategoryRowReference: @@ -94,32 +97,63 @@ class ApplicationMetadata(object): Start asynchronous Entropy Metadata retrieveal. """ ApplicationMetadata._RATING_THREAD.start() + ApplicationMetadata._ICON_THREAD.start() @staticmethod def _rating_thread_body(): """ Thread executing package rating remote data retrieval. """ + request_list = ["vote", "down"] + return ApplicationMetadata._generic_thread_body( + "RatingThread", ApplicationMetadata._RATING_SEM, + ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL, + ApplicationMetadata._RATING_THREAD_SLEEP_SECS, + ApplicationMetadata._RATING_QUEUE, + ApplicationMetadata._RATING_LOCK, + ApplicationMetadata._RATING_IN_FLIGHT, + request_list) + + @staticmethod + def _icon_thread_body(): + """ + Thread executing package icon remote data retrieval. + """ + request_list = ["icon"] + return ApplicationMetadata._generic_thread_body( + "IconThread", ApplicationMetadata._ICON_SEM, + ApplicationMetadata._ICON_THREAD_DISCARD_SIGNAL, + ApplicationMetadata._ICON_THREAD_SLEEP_SECS, + ApplicationMetadata._ICON_QUEUE, + ApplicationMetadata._ICON_LOCK, + ApplicationMetadata._ICON_IN_FLIGHT, + request_list) + + @staticmethod + def _generic_thread_body(name, sem, discard_signal, sleep_secs, + queue, mutex, in_flight, request_list): + """ + Thread executing generic (both rating and doc) metadata retrieval. + """ while True: - ApplicationMetadata._RATING_SEM.acquire() - ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL = False + sem.acquire() + discard_signal.set(False) const_debug_write(__name__, - "_rating_thread_body, waking up") + "%s, waking up" % (name,)) # sleep a bit in order to catch more flies - time.sleep(ApplicationMetadata._RATING_THREAD_SLEEP_SECS) + time.sleep(sleep_secs) # now catch the flies local_queue = [] while True: try: - local_queue.append( - ApplicationMetadata._RATING_QUEUE.popleft()) + local_queue.append(queue.popleft()) except IndexError: # no more items break if const_debug_enabled(): const_debug_write(__name__, - "_rating_thread_body, got: %s" % (local_queue,)) + "%s, got: %s" % (name, local_queue,)) if not local_queue: continue @@ -136,8 +170,7 @@ class ApplicationMetadata(object): obj = repo_map.setdefault(repo_id, set()) obj.add(key) - votes_map = {} - downloads_map = {} + request_outcome = {} # issue requests for repo_id, keys in repo_map.items(): @@ -146,65 +179,63 @@ class ApplicationMetadata(object): if webserv is None: continue + request_map = { + "vote": webserv.get_votes, + "down": webserv.get_downloads, + "icon": webserv.get_icons, + } + # FIXME: lxnay, who validates this cache? - uncached_keys = [] - for key in keys: - try: - votes_map[(key, repo_id)] = webserv.get_votes( - [key], cache=True, cached=True)[key] - except WebService.CacheMiss: - uncached_keys.append(key) + for request in request_list: + outcome = {} - for key in uncached_keys: - if ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL: - break - try: - # FIXME, lxnay: work more instances in parallel? - votes_map[(key, repo_id)] = webserv.get_votes( - [key], cache = True)[key] - except WebService.WebServiceException as wse: - const_debug_write( - __name__, - "_rating_thread_body, WebServiceExc: %s" % (wse,)) - votes_map[(key, repo_id)] = None + uncached_keys = [] + for key in keys: + try: + outcome[(key, repo_id)] = request_map[request]( + [key], cache=True, cached=True)[key] + except WebService.CacheMiss: + uncached_keys.append(key) - uncached_keys = [] - for key in keys: - try: - downloads_map[(key, repo_id)] = webserv.get_downloads( - [key], cache=True, cached=True)[key] - except WebService.CacheMiss: - uncached_keys.append(key) + for key in uncached_keys: + if discard_signal.get(): + break + try: + # FIXME, lxnay: work more instances in parallel? + outcome[(key, repo_id)] = request_map[request]( + [key], cache = True)[key] + except WebService.WebServiceException as wse: + const_debug_write( + __name__, + "%s, WebServiceExc: %s" % (name, wse,) + ) + outcome[(key, repo_id)] = None - for key in uncached_keys: - if ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL: - break - try: - # FIXME, lxnay: work more instances in parallel? - downloads_map[(key, repo_id)] = webserv.get_downloads( - [key], cache = True)[key] - except WebService.WebServiceException as wse: - const_debug_write( - __name__, - "_rating_thread_body, WebServiceExc: %s" % (wse,)) - downloads_map[(key, repo_id)] = None + request_outcome[request] = outcome - if ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL: - ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL = False + # don't worry about races + if discard_signal.get(): + const_debug_write( + __name__, + "%s, discard signal received." % (name,) + ) + discard_signal.set(False) + request_outcome.clear() continue # dispatch results for (key, repo_id), cb_ts_list in pkg_key_map.items(): for (cb, ts) in cb_ts_list: - with ApplicationMetadata._RATING_LOCK: - ApplicationMetadata._RATING_IN_FLIGHT.discard( - (key, repo_id)) + with mutex: + in_flight.discard((key, repo_id)) if cb is not None: - vote = votes_map.get((key, repo_id)) - down = downloads_map.get((key, repo_id)) - task = ParallelTask(cb, (vote, down), ts) - task.name = "RatingThreadCb{%s, %s}" % (repo_id, key) + outcome_values = [] + for request in request_list: + outcome_values.append( + request_outcome[request].get((key, repo_id))) + task = ParallelTask(cb, outcome_values, ts) + task.name = "%sCb{%s, %s}" % (name, repo_id, key) task.start() @staticmethod @@ -213,19 +244,26 @@ class ApplicationMetadata(object): Get Entropy WebService object for repository """ webserv = ApplicationMetadata._WEBSERV_CACHE.get(repository_id) - if webserv == -1: return None # not available if webserv is not None: return webserv - try: - webserv = get_entropy_webservice(entropy_client, repository_id) - except WebService.UnsupportedService: - ApplicationMetadata._WEBSERV_CACHE[repository_id] = -1 - return None + with ApplicationMetadata._WEBSERV_MUTEX: + webserv = ApplicationMetadata._WEBSERV_CACHE.get(repository_id) + if webserv == -1: + return None # not available + if webserv is not None: + return webserv + + try: + webserv = get_entropy_webservice(entropy_client, repository_id) + except WebService.UnsupportedService: + ApplicationMetadata._WEBSERV_CACHE[repository_id] = -1 + return None + + ApplicationMetadata._WEBSERV_CACHE[repository_id] = webserv - ApplicationMetadata._WEBSERV_CACHE[repository_id] = webserv return webserv @staticmethod @@ -234,34 +272,88 @@ class ApplicationMetadata(object): Discard all the queued requests. No longer needed. """ const_debug_write(__name__, - "!!! ApplicationMetadata.discard() called !!!") - while True: - try: - ApplicationMetadata._RATING_QUEUE.popleft() - # we could use blocking mode, but no actual need - ApplicationMetadata._RATING_SEM.acquire(False) - except IndexError: - break - with ApplicationMetadata._RATING_LOCK: - ApplicationMetadata._RATING_IN_FLIGHT.clear() - ApplicationMetadata._RATING_THREAD_DISCARD_SIGNAL = True + "ApplicationMetadata.discard() called") + for th_info in ApplicationMetadata._REGISTERED_THREAD_INFO: + th, queue, sem, lock, \ + discard_signal, in_flight = th_info + while True: + try: + queue.popleft() + # we could use blocking mode, but no actual need + sem.acquire(False) + except IndexError: + break + with lock: + in_flight.clear() + discard_signal.set(True) @staticmethod - def enqueue_rating(entropy_client, package_key, repository_id, callback): + def _download_document(entropy_ws, document, cache=True): """ - Enqueue the Rating (stars) for package key in given repository. + Dowload Document (Icon, File, Image, etc) through the Entropy + WebService interface. + Return path to just downloaded Document if success, None otherwise. + """ + local_path = None + try: + local_path = entropy_ws.get_document_url(document, + cache=cache) + except ClientWebService.DocumentError as err: + const_debug_write(__name__, + "_download_document: document error: %s" % ( + err,)) + return local_path + + @staticmethod + def _enqueue_rating(entropy_client, package_key, repository_id, callback): + """ + Enqueue the retriveal 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. callback() signature is: callback(payload, request_timestamp_float). If data is not available, payload will be None. callback argument can be None. + _RATING_LOCK must be acquired by caller. """ + if const_debug_enabled(): + const_debug_write( + __name__, + "_enqueue_rating: %s, %s" % (package_key, repository_id)) request_time = time.time() - ApplicationMetadata._RATING_IN_FLIGHT.add((package_key, repository_id)) - ApplicationMetadata._RATING_QUEUE.append((entropy_client, package_key, + in_flight = ApplicationMetadata._RATING_IN_FLIGHT + queue = ApplicationMetadata._RATING_QUEUE + sem = ApplicationMetadata._RATING_SEM + in_flight.add((package_key, repository_id)) + queue.append((entropy_client, package_key, repository_id, callback, request_time)) - ApplicationMetadata._RATING_SEM.release() + sem.release() + + @staticmethod + def _enqueue_icon(entropy_client, package_key, repository_id, callback): + """ + Enqueue the retrieval of the Icon 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. + callback() signature is: callback(payload, request_timestamp_float). + If data is not available, payload will be None. + callback argument can be None. + _ICON_LOCK must be acquired by caller. + """ + if const_debug_enabled(): + const_debug_write( + __name__, + "_enqueue_icon: %s, %s" % (package_key, repository_id)) + + request_time = time.time() + in_flight = ApplicationMetadata._ICON_IN_FLIGHT + queue = ApplicationMetadata._ICON_QUEUE + sem = ApplicationMetadata._ICON_SEM + in_flight.add((package_key, repository_id)) + queue.append((entropy_client, package_key, + repository_id, callback, request_time)) + sem.release() @staticmethod def lazy_get_rating(entropy_client, package_key, repository_id, @@ -284,12 +376,16 @@ class ApplicationMetadata(object): down = webserv.get_downloads( [package_key], cache=True, cached=True)[package_key] except WebService.CacheMiss: + if const_debug_enabled(): + const_debug_write(__name__, + "lazy_get_rating: cache miss for: %s, %s" % ( + package_key, repository_id)) # not in cache flight_key = (package_key, repository_id) with ApplicationMetadata._RATING_LOCK: if flight_key not in ApplicationMetadata._RATING_IN_FLIGHT: # enqueue a new rating then - ApplicationMetadata.enqueue_rating( + ApplicationMetadata._enqueue_rating( entropy_client, package_key, repository_id, callback) vote = None @@ -297,20 +393,124 @@ class ApplicationMetadata(object): return vote, down + @staticmethod + def lazy_get_icon(entropy_client, package_key, repository_id, + callback=None): + """ + Return a DocumentList of Icons 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, 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. + """ + webserv = ApplicationMetadata._get_webservice( + entropy_client, repository_id) + if webserv is None: + return None + def _pick_icon(icons): + return icons[0] + + def _icon_callback(outcomes, ts): + icons = outcomes[0] + if not icons: + # sadly, no icons + return + local_path = ApplicationMetadata._download_document( + webserv, _pick_icon(icons)) + if local_path: + # only if successful, otherwise we fall into + # infinite loop + callback(icons) + + try: + icons = webserv.get_icons( + [package_key], cache=True, cached=True)[package_key] + except WebService.CacheMiss: + if const_debug_enabled(): + const_debug_write(__name__, + "lazy_get_icon: cache miss for: %s, %s" % ( + package_key, repository_id)) + # not in cache + flight_key = (package_key, repository_id) + with ApplicationMetadata._ICON_LOCK: + if flight_key not in ApplicationMetadata._ICON_IN_FLIGHT: + # enqueue a new rating then + ApplicationMetadata._enqueue_icon( + entropy_client, package_key, + repository_id, _icon_callback) + icons = None + + if not icons: + return None + + # 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): + task = ParallelTask(_icon_callback, [icons], time.time()) + task.daemon = True + task.name = "FetchIconCb{(%s, %s)}" % ((package_key, repository_id)) + task.start() + + return icon + + class SignalBoolean(object): + + def __init__(self, val): + self.__val = val + + def set(self, val): + self.__val = val + + def get(self): + return self.__val + + # Application Rating logic _RATING_QUEUE = deque() def _rating_thread_body_wrapper(): return ApplicationMetadata._rating_thread_body() _RATING_THREAD = ParallelTask(_rating_thread_body_wrapper) _RATING_THREAD.daemon = True _RATING_THREAD.name = "RatingThread" - _RATING_THREAD_SLEEP_SECS = 1.5 - _RATING_THREAD_DISCARD_SIGNAL = False + _RATING_THREAD_SLEEP_SECS = 1.0 + _RATING_THREAD_DISCARD_SIGNAL = SignalBoolean(False) _RATING_SEM = Semaphore(0) _RATING_LOCK = Lock() _RATING_IN_FLIGHT = set() + # Application Documents logic + _ICON_QUEUE = deque() + def _icon_thread_body_wrapper(): + return ApplicationMetadata._icon_thread_body() + _ICON_THREAD = ParallelTask(_icon_thread_body_wrapper) + _ICON_THREAD.daemon = True + _ICON_THREAD.name = "IconThread" + _ICON_THREAD_SLEEP_SECS = 0.5 + _ICON_THREAD_DISCARD_SIGNAL = SignalBoolean(False) + _ICON_SEM = Semaphore(0) + _ICON_LOCK = Lock() + _ICON_IN_FLIGHT = set() + + _REGISTERED_THREAD_INFO = [ + # rating + (_RATING_THREAD, _RATING_QUEUE, + _RATING_SEM, _RATING_LOCK, + _RATING_THREAD_DISCARD_SIGNAL, + _RATING_IN_FLIGHT), + # icon + (_ICON_THREAD, _ICON_QUEUE, + _ICON_SEM, _ICON_LOCK, + _ICON_THREAD_DISCARD_SIGNAL, + _ICON_IN_FLIGHT), + ] + + # WebService object cache _WEBSERV_CACHE = {} + _WEBSERV_MUTEX = Lock() # this is a very lean class as its used in the main listview @@ -354,7 +554,7 @@ class Application(object): def get_markup(self): repo = self._entropy.open_repository(self._repo_id) - name = self.name + name = repo.retrieveName(self._pkg_id) version = repo.retrieveVersion(self._pkg_id) if version is None: version = "N/A" @@ -370,6 +570,13 @@ class Application(object): return text def get_review_stats(self): + """ + Return ReviewStats object containing user review + information about this Application, like + votes and number of downloads. + FIXME, lxnay: move to AppDetails() + """ + stat = ReviewStats(self) stat.ratings_average = ReviewStats.NO_RATING @@ -389,13 +596,16 @@ class Application(object): stat.ratings_average = vote if down is not None: # otherwise 0 is shown - stat.ratings_total = down + stat.downloads_total = down return stat # get a AppDetails object for this Applications def get_details(self): - """ return a new AppDetails object for this application """ - return AppDetails(self._entropy, self._pkg_match, self) + """ + Return a new AppDetails object for this application + """ + return AppDetails(self._entropy, self._pkg_match, self, + redraw_callback=self._redraw_callback) def __str__(self): repo = self._entropy.open_repository(self._repo_id) @@ -413,7 +623,8 @@ class AppDetails(object): we have available like website etc """ - def __init__(self, entropy_client, package_match, app): + def __init__(self, entropy_client, package_match, app, + redraw_callback=None): """ Create a new AppDetails object. """ @@ -421,6 +632,7 @@ class AppDetails(object): self._pkg_match = package_match self._pkg_id, self._repo_id = package_match self._app = app + self._redraw_callback = redraw_callback @property def channelname(self): @@ -447,9 +659,30 @@ class AppDetails(object): @property def icon(self): - """Return icon name, as it should be searched through fd.o dbs""" - # FIXME, we have it - return Icons.MISSING_PKG + """ + Return Application Icon image path. + In case of missing icon, None is returned. + """ + repo = self._entropy.open_repository(self._repo_id) + key_slot = repo.retrieveKeySlot(self._pkg_id) + if key_slot is None: + return None + + key, slot = key_slot + icon = ApplicationMetadata.lazy_get_icon( + self._entropy, key, self._repo_id, + callback=self._redraw_callback) + if const_debug_enabled(): + const_debug_write(__name__, + "AppDetails{%s}.icon: icon: %s" % ( + self._pkg_match, + icon)) + + if not icon: + return None + # pick the first one + icon_path = icon.local_document() + return icon_path @property def icon_file_name(self): diff --git a/rigo/rigo/ui/gtk3/utils.py b/rigo/rigo/ui/gtk3/utils.py index b254d28c8..244fdf276 100644 --- a/rigo/rigo/ui/gtk3/utils.py +++ b/rigo/rigo/ui/gtk3/utils.py @@ -20,8 +20,10 @@ #gi.require_version("Gtk", "3.0") import os import logging +import shutil +import tempfile -from gi.repository import Gtk +from gi.repository import Gtk, GdkPixbuf, GObject from rigo.paths import ICON_PATH @@ -85,3 +87,54 @@ def get_sc_icon_theme(datadir): return icons +def resize_image(max_width, image_path, final_image_path): + dirname = os.path.dirname(final_image_path) + tmp_fd, new_image_path = tempfile.mkstemp( + dir=dirname, prefix="resize_image") + os.close(tmp_fd) + + shutil.copy2(image_path, new_image_path) + img = Gtk.Image() + img.set_from_file(new_image_path) + img_buf = img.get_pixbuf() + w, h = img_buf.get_width(), img_buf.get_height() + if w > max_width: + # resize pix + new_w = max_width + new_h = new_w * h / w + img_buf = img_buf.scale_simple(int(new_w), + int(new_h), GdkPixbuf.InterpType.BILINEAR) + try: + img_buf.save(new_image_path, "png") + except GObject.GError: + # libpng issue? try jpeg + img_buf.save(new_image_path, "jpeg") + del img_buf + del img + os.rename(new_image_path, final_image_path) + +def resize_image_height(max_height, image_path, final_image_path): + dirname = os.path.dirname(final_image_path) + tmp_fd, new_image_path = tempfile.mkstemp( + dir=dirname, prefix="resize_image") + os.close(tmp_fd) + + shutil.copy2(image_path, new_image_path) + img = Gtk.Image() + img.set_from_file(new_image_path) + img_buf = img.get_pixbuf() + w, h = img_buf.get_width(), img_buf.get_height() + if h > max_height: + # resize pix + new_h = max_height + new_w = new_h*w/h + img_buf = img_buf.scale_simple(int(new_w), + int(new_h), GdkPixbuf.InterpType.BILINEAR) + try: + img_buf.save(new_image_path, "png") + except GObject.GError: + # libpng issue? try jpeg + img_buf.save(new_image_path, "jpeg") + del img_buf + del img + os.rename(new_image_path, final_image_path) diff --git a/rigo/rigo/ui/gtk3/widgets/buttons.py b/rigo/rigo/ui/gtk3/widgets/buttons.py index efa1054f4..cda80c5e6 100644 --- a/rigo/rigo/ui/gtk3/widgets/buttons.py +++ b/rigo/rigo/ui/gtk3/widgets/buttons.py @@ -220,7 +220,7 @@ class FeaturedTile(TileButton): self.rating_box.pack_start(self.stars, False, False, 0) self.n_ratings = Gtk.Label.new( ' (%i)' % ( - em(0.45), stats.ratings_total)) + em(0.45), stats.downloads_total)) self.n_ratings.set_use_markup(True) self.n_ratings.set_name("subtle-label") self.n_ratings.set_alignment(0.0, 0.5) @@ -229,7 +229,8 @@ class FeaturedTile(TileButton): # TRANSLATORS: this is an accessibility description for eg orca and # is not visible in the ui stats_a11y = _('%(stars)d stars - %(reviews)d reviews') % { - 'stars': stats.ratings_average, 'reviews': stats.ratings_total} + 'stars': stats.ratings_average, + 'reviews': stats.downloads_total} #work out width tile needs to be to ensure ratings text is all visible req_width = (self.stars.size_request().width + diff --git a/rigo/rigo/ui/gtk3/widgets/cellrenderers.py b/rigo/rigo/ui/gtk3/widgets/cellrenderers.py index ad12c4daa..3e96c4bc1 100644 --- a/rigo/rigo/ui/gtk3/widgets/cellrenderers.py +++ b/rigo/rigo/ui/gtk3/widgets/cellrenderers.py @@ -212,7 +212,7 @@ class CellRendererAppView(Gtk.CellRendererText): sr.render_star(context, cr, x, y) # and nr-reviews in parenthesis to the right of the title - nreviews = stats.ratings_total + nreviews = stats.downloads_total s = "(%i)" % nreviews layout.set_markup("%s" % s, -1) diff --git a/rigo/rigo_app.py b/rigo/rigo_app.py index 189a0e001..9a67873bc 100644 --- a/rigo/rigo_app.py +++ b/rigo/rigo_app.py @@ -12,23 +12,22 @@ sys.path.insert(5, "/usr/lib/entropy/rigo") from threading import Lock -from gi.repository import Gtk, Gdk, Gio, GLib, GObject +from gi.repository import Gtk, Gdk, Gio, GLib, GObject, GdkPixbuf from rigo.paths import DATA_DIR from rigo.enums import Icons from rigo.models.application import Application, ApplicationMetadata from rigo.ui.gtk3.widgets.apptreeview import AppTreeView -from rigo.ui.gtk3.utils import init_sc_css_provider, get_sc_icon_theme +from rigo.ui.gtk3.utils import init_sc_css_provider, get_sc_icon_theme, \ + resize_image -from entropy.const import etpUi +from entropy.const import etpUi, const_debug_write, const_debug_enabled from entropy.tools import kill_threads from entropy.exceptions import RepositoryError from entropy.client.interfaces import Client from entropy.misc import TimeScheduled, ParallelTask from entropy.i18n import _ -etpUi['debug'] = True - class EntropyWebService(object): def __init__(self, entropy_client): @@ -105,56 +104,95 @@ class AppListStore(Gtk.ListStore): # column id COL_ROW_DATA = 0 - # default icon size displayed in the treeview - ICON_SIZE = 32 + # default icon size returned by Application.get_icon() + ICON_SIZE = 48 + _MISSING_ICON = None + _MISSING_ICON_MUTEX = Lock() + _ICON_CACHE = {} - def __init__(self, entropy_client, view, icons, icon_size=48): + def __init__(self, entropy_client, view, icons): Gtk.ListStore.__init__(self) self._view = view self._entropy = entropy_client self._icons = icons - self._icon_size = icon_size - self._icon_cache = {} - self._missing_icon = self._icons.load_icon( - Icons.MISSING_APP, icon_size, 0) self.set_column_types(self.COL_TYPES) # Startup Entropy Package Metadata daemon ApplicationMetadata.start() + def clear(self): + """ + Clear ListStore content (and Icon Cache). + """ + outcome = Gtk.ListStore.clear(self) + AppListStore._ICON_CACHE.clear() + return outcome + def _ui_redraw_callback(self, *args): + if const_debug_enabled(): + const_debug_write(__name__, + "_ui_redraw_callback()") GLib.idle_add(self._view.queue_draw) + @property + def _missing_icon(self): + """ + Return the missing icon Gtk.Image() if needed. + """ + if AppListStore._MISSING_ICON is not None: + return AppListStore._MISSING_ICON + with AppListStore._MISSING_ICON_MUTEX: + if AppListStore._MISSING_ICON is not None: + return AppListStore._MISSING_ICON + _missing_icon = self._icons.load_icon( + Icons.MISSING_APP, AppListStore.ICON_SIZE, 0) + AppListStore._MISSING_ICON = _missing_icon + return _missing_icon + def get_icon(self, pkg_match): - # FIXME, parallel load from UGC? - # or run UGC on separate task? + cached = AppListStore._ICON_CACHE.get(pkg_match) + if cached is not None: + return cached + app = Application(self._entropy, pkg_match, redraw_callback=self._ui_redraw_callback) - name = app.name - icon = self._icon_cache.get(name) - if icon == -1: - # icon not available + icon_path = app.get_details().icon + if icon_path is None: return self._missing_icon - if icon is not None: - return icon - if self._icons.has_icon(name): + if not os.path.isfile(icon_path): + return self._missing_icon + + # FIXME, parallelize this? + # get current image size + # expensive? + img = Gtk.Image() + img.set_from_file(icon_path) + img_buf = img.get_pixbuf() + if img_buf is None: + # wth, invalid crap + return self._missing_icon + w, h = img_buf.get_width(), img_buf.get_height() + del img_buf + del img + if w < 1: + # not legit + return self._missing_icon + width = AppListStore.ICON_SIZE + height = width * h / w + + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + icon_path, width, height) + except GObject.GError: try: - icon = self._icons.load_icon( - name, self._icon_size, 0) - except (Gio.Error, GObject.GError): - # no such file or directory (gio.Error) - # unrecognized file format (gobject.GError) - icon = None - if icon: - self._icon_cache[name] = icon - else: - self._icon_cache[name] = -1 - icon = self._missing_icon - else: - self._icon_cache[name] = -1 - icon = self._missing_icon - return icon + os.remove(icon_path) + except OSError: + pass + return self._missing_icon + + AppListStore._ICON_CACHE[pkg_match] = pixbuf + return pixbuf def is_installed(self, pkg_match): app = Application(self._entropy, pkg_match, @@ -279,7 +317,7 @@ class WelcomeBox(Gtk.VBox): image = Gtk.Image.new_from_file(self._image_path) label = Gtk.Label() label.set_markup(_("Browse Applications with ease")) - self.pack_start(image, False, False, False) + self.pack_start(image, False, False, 0) self.pack_start(label, False, False, 0) label.show() image.show()