Fixed GUI performance problem with large number of feeds
[zeroinstall/solver.git] / zeroinstall / 0launch-gui / iface_browser.py
blobcc2a88007c6ddc2573fb2d15eec2f6187eb3f150
1 # Copyright (C) 2009, Thomas Leonard
2 # See the README file for details, or visit http://0install.net.
4 import gtk, gobject, pango
6 from zeroinstall import _, translation
7 from zeroinstall.support import tasks, pretty_size
8 from zeroinstall.injector import model, reader
9 import properties
10 from zeroinstall.gtkui.icon import load_icon
11 from zeroinstall import support
12 from logging import warn, info
13 import utils
15 ngettext = translation.ngettext
17 def _stability(impl):
18 assert impl
19 if impl.user_stability is None:
20 return _(str(impl.upstream_stability))
21 return _("%(implementation_user_stability)s (was %(implementation_upstream_stability)s)") \
22 % {'implementation_user_stability': _(str(impl.user_stability)),
23 'implementation_upstream_stability': _(str(impl.upstream_stability))}
25 ICON_SIZE = 20.0
26 CELL_TEXT_INDENT = int(ICON_SIZE) + 4
28 def get_tooltip_text(mainwindow, interface, main_feed, model_column):
29 assert interface
30 if model_column == InterfaceBrowser.INTERFACE_NAME:
31 return _("Full name: %s") % interface.uri
32 elif model_column == InterfaceBrowser.SUMMARY:
33 if main_feed is None or not main_feed.description:
34 return _("(no description available)")
35 first_para = main_feed.description.split('\n\n', 1)[0]
36 return first_para.replace('\n', ' ')
37 elif model_column is None:
38 return _("Click here for more options...")
40 impl = mainwindow.policy.implementation.get(interface, None)
41 if not impl:
42 return _("No suitable version was found. Double-click "
43 "here to find out why.")
45 if model_column == InterfaceBrowser.VERSION:
46 text = _("Currently preferred version: %(version)s (%(stability)s)") % \
47 {'version': impl.get_version(), 'stability': _stability(impl)}
48 old_impl = mainwindow.original_implementation.get(interface, None)
49 if old_impl is not None and old_impl is not impl:
50 text += '\n' + _('Previously preferred version: %(version)s (%(stability)s)') % \
51 {'version': old_impl.get_version(), 'stability': _stability(old_impl)}
52 return text
54 assert model_column == InterfaceBrowser.DOWNLOAD_SIZE
56 if mainwindow.policy.get_cached(impl):
57 return _("This version is already stored on your computer.")
58 else:
59 src = mainwindow.policy.fetcher.get_best_source(impl)
60 if not src:
61 return _("No downloads available!")
62 return _("Need to download %(pretty_size)s (%(size)s bytes)") % \
63 {'pretty_size': support.pretty_size(src.size), 'size': src.size}
65 class MenuIconRenderer(gtk.GenericCellRenderer):
66 def __init__(self):
67 gtk.GenericCellRenderer.__init__(self)
68 self.set_property('mode', gtk.CELL_RENDERER_MODE_ACTIVATABLE)
70 def do_set_property(self, prop, value):
71 setattr(self, prop.name, value)
73 def on_get_size(self, widget, cell_area, layout = None):
74 return (0, 0, 20, 20)
76 def on_render(self, window, widget, background_area, cell_area, expose_area, flags):
77 if flags & gtk.CELL_RENDERER_PRELIT:
78 state = gtk.STATE_PRELIGHT
79 else:
80 state = gtk.STATE_NORMAL
82 widget.style.paint_box(window, state, gtk.SHADOW_OUT, expose_area, widget, None,
83 cell_area.x, cell_area.y, cell_area.width, cell_area.height)
84 widget.style.paint_arrow(window, state, gtk.SHADOW_NONE, expose_area, widget, None,
85 gtk.ARROW_RIGHT, True,
86 cell_area.x + 5, cell_area.y + 5, cell_area.width - 10, cell_area.height - 10)
88 class IconAndTextRenderer(gtk.GenericCellRenderer):
89 __gproperties__ = {
90 "image": (gobject.TYPE_OBJECT, "Image", "Image", gobject.PARAM_READWRITE),
91 "text": (gobject.TYPE_STRING, "Text", "Text", "-", gobject.PARAM_READWRITE),
94 def do_set_property(self, prop, value):
95 setattr(self, prop.name, value)
97 def on_get_size(self, widget, cell_area, layout = None):
98 if not layout:
99 layout = widget.create_pango_layout(self.text)
100 a, rect = layout.get_pixel_extents()
102 pixmap_height = self.image.get_height()
104 both_height = max(rect[1] + rect[3], pixmap_height)
106 return (0, 0,
107 rect[0] + rect[2] + CELL_TEXT_INDENT,
108 both_height)
110 def on_render(self, window, widget, background_area, cell_area, expose_area, flags):
111 layout = widget.create_pango_layout(self.text)
112 a, rect = layout.get_pixel_extents()
114 if flags & gtk.CELL_RENDERER_SELECTED:
115 state = gtk.STATE_SELECTED
116 elif flags & gtk.CELL_RENDERER_PRELIT:
117 state = gtk.STATE_PRELIGHT
118 else:
119 state = gtk.STATE_NORMAL
121 image_y = int(0.5 * (cell_area.height - self.image.get_height()))
122 window.draw_pixbuf(widget.style.white_gc, self.image, 0, 0,
123 cell_area.x,
124 cell_area.y + image_y)
126 text_y = int(0.5 * (cell_area.height - (rect[1] + rect[3])))
128 widget.style.paint_layout(window, state, True,
129 expose_area, widget, "cellrenderertext",
130 cell_area.x + CELL_TEXT_INDENT,
131 cell_area.y + text_y,
132 layout)
134 if gtk.pygtk_version < (2, 8, 0):
135 # Note sure exactly which versions need this.
136 # 2.8.0 gives a warning if you include it, though.
137 gobject.type_register(IconAndTextRenderer)
138 gobject.type_register(MenuIconRenderer)
140 def walk(model, it):
141 while it:
142 yield it
143 for x in walk(model, model.iter_children(it)): yield x
144 it = model.iter_next(it)
146 class InterfaceBrowser:
147 model = None
148 root = None
149 cached_icon = None
150 policy = None
151 original_implementation = None
152 update_icons = False
154 INTERFACE = 0
155 INTERFACE_NAME = 1
156 VERSION = 2
157 SUMMARY = 3
158 DOWNLOAD_SIZE = 4
159 ICON = 5
160 BACKGROUND = 6
161 PROBLEM = 7
163 columns = [(_('Component'), INTERFACE_NAME),
164 (_('Version'), VERSION),
165 (_('Fetch'), DOWNLOAD_SIZE),
166 (_('Description'), SUMMARY),
167 ('', None)]
169 def __init__(self, policy, widgets):
170 tree_view = widgets.get_widget('components')
171 tree_view.set_property('has-tooltip', True)
172 def callback(widget, x, y, keyboard_mode, tooltip):
173 x, y = tree_view.convert_widget_to_bin_window_coords(x, y)
174 pos = tree_view.get_path_at_pos(x, y)
175 if pos:
176 tree_view.set_tooltip_cell(tooltip, pos[0], pos[1], None)
177 path = pos[0]
178 try:
179 col_index = column_objects.index(pos[1])
180 except ValueError:
181 return False
182 else:
183 col = self.columns[col_index][1]
184 row = self.model[path]
185 iface = row[InterfaceBrowser.INTERFACE]
186 main_feed = self.policy.config.iface_cache.get_feed(iface.uri)
187 tooltip.set_text(get_tooltip_text(self, iface, main_feed, col))
188 return True
189 else:
190 return False
191 tree_view.connect('query-tooltip', callback)
193 self.policy = policy
194 self.cached_icon = {} # URI -> GdkPixbuf
195 self.default_icon = tree_view.style.lookup_icon_set(gtk.STOCK_EXECUTE).render_icon(tree_view.style,
196 gtk.TEXT_DIR_NONE, gtk.STATE_NORMAL, gtk.ICON_SIZE_SMALL_TOOLBAR, tree_view, None)
198 self.model = gtk.TreeStore(object, str, str, str, str, gtk.gdk.Pixbuf, str, bool)
199 self.tree_view = tree_view
200 tree_view.set_model(self.model)
202 column_objects = []
204 text = gtk.CellRendererText()
205 coloured_text = gtk.CellRendererText()
207 for name, model_column in self.columns:
208 if model_column == InterfaceBrowser.INTERFACE_NAME:
209 column = gtk.TreeViewColumn(name, IconAndTextRenderer(),
210 text = model_column,
211 image = InterfaceBrowser.ICON)
212 elif model_column == None:
213 menu_column = column = gtk.TreeViewColumn('', MenuIconRenderer())
214 else:
215 if model_column == InterfaceBrowser.SUMMARY:
216 text_ellip = gtk.CellRendererText()
217 try:
218 text_ellip.set_property('ellipsize', pango.ELLIPSIZE_END)
219 except:
220 pass
221 column = gtk.TreeViewColumn(name, text_ellip, text = model_column)
222 column.set_expand(True)
223 elif model_column == InterfaceBrowser.VERSION:
224 column = gtk.TreeViewColumn(name, coloured_text, text = model_column,
225 background = InterfaceBrowser.BACKGROUND)
226 else:
227 column = gtk.TreeViewColumn(name, text, text = model_column)
228 tree_view.append_column(column)
229 column_objects.append(column)
231 tree_view.set_enable_search(True)
233 selection = tree_view.get_selection()
235 def button_press(tree_view, bev):
236 pos = tree_view.get_path_at_pos(int(bev.x), int(bev.y))
237 if not pos:
238 return False
239 path, col, x, y = pos
241 if (bev.button == 3 or (bev.button < 4 and col is menu_column)) \
242 and bev.type == gtk.gdk.BUTTON_PRESS:
243 selection.select_path(path)
244 iface = self.model[path][InterfaceBrowser.INTERFACE]
245 self.show_popup_menu(iface, bev)
246 return True
247 if bev.button != 1 or bev.type != gtk.gdk._2BUTTON_PRESS:
248 return False
249 properties.edit(policy, self.model[path][InterfaceBrowser.INTERFACE], self.compile, show_versions = True)
250 tree_view.connect('button-press-event', button_press)
252 tree_view.connect('destroy', lambda s: policy.watchers.remove(self.build_tree))
253 policy.watchers.append(self.build_tree)
255 def set_root(self, root):
256 assert isinstance(root, model.Interface)
257 self.root = root
259 def set_update_icons(self, update_icons):
260 if update_icons:
261 # Clear icons cache to make sure they're really updated
262 self.cached_icon = {}
263 self.update_icons = update_icons
265 def get_icon(self, iface):
266 """Get an icon for this interface. If the icon is in the cache, use that.
267 If not, start a download. If we already started a download (successful or
268 not) do nothing. Returns None if no icon is currently available."""
269 try:
270 # Try the in-memory cache
271 return self.cached_icon[iface.uri]
272 except KeyError:
273 # Try the on-disk cache
274 iconpath = self.policy.config.iface_cache.get_icon_path(iface)
276 if iconpath:
277 icon = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
278 # (if icon is None, cache the fact that we can't load it)
279 self.cached_icon[iface.uri] = icon
280 else:
281 icon = None
283 # Download a new icon if we don't have one, or if the
284 # user did a 'Refresh'
285 if iconpath is None or self.update_icons:
286 fetcher = self.policy.download_icon(iface)
287 if fetcher:
288 if iface.uri not in self.cached_icon:
289 self.cached_icon[iface.uri] = None # Only try once
291 @tasks.async
292 def update_display():
293 yield fetcher
294 try:
295 tasks.check(fetcher)
296 # Try to insert new icon into the cache
297 # If it fails, we'll be left with None in the cached_icon so
298 # we don't try again.
299 iconpath = self.policy.config.iface_cache.get_icon_path(iface)
300 if iconpath:
301 self.cached_icon[iface.uri] = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
302 self.build_tree()
303 else:
304 warn("Failed to download icon for '%s'", iface)
305 except Exception as ex:
306 import traceback
307 traceback.print_exc()
308 self.policy.handler.report_error(ex)
309 update_display()
310 # elif fetcher is None: don't store anything in cached_icon
312 # Note: if no icon is available for downloading,
313 # more attempts are made later.
314 # It can happen that no icon is yet available because
315 # the interface was not downloaded yet, in which case
316 # it's desireable to try again once the interface is available
317 return icon
319 return None
321 def build_tree(self):
322 iface_cache = self.policy.config.iface_cache
324 if self.original_implementation is None:
325 self.set_original_implementations()
327 done = {} # Detect cycles
329 self.model.clear()
330 commands = self.policy.solver.selections.commands
331 def add_node(parent, iface, command, essential):
332 # (command is the index into commands, if any)
333 if iface in done:
334 return
335 done[iface] = True
337 main_feed = iface_cache.get_feed(iface.uri)
338 if main_feed:
339 name = main_feed.get_name()
340 summary = main_feed.summary
341 else:
342 name = iface.get_name()
343 summary = None
345 iter = self.model.append(parent)
346 self.model[iter][InterfaceBrowser.INTERFACE] = iface
347 self.model[iter][InterfaceBrowser.INTERFACE_NAME] = name
348 self.model[iter][InterfaceBrowser.SUMMARY] = summary
349 self.model[iter][InterfaceBrowser.ICON] = self.get_icon(iface) or self.default_icon
350 self.model[iter][InterfaceBrowser.PROBLEM] = False
352 sel = self.policy.solver.selections.selections.get(iface.uri, None)
353 if sel:
354 impl = sel.impl
355 old_impl = self.original_implementation.get(iface, None)
356 version_str = impl.get_version()
357 if old_impl is not None and old_impl.id != impl.id:
358 version_str += _(' (was %s)') % old_impl.get_version()
359 self.model[iter][InterfaceBrowser.VERSION] = version_str
361 self.model[iter][InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.policy, impl)
363 deps = sel.dependencies
364 if command is not None:
365 deps += commands[command].requires
366 for child in deps:
367 if isinstance(child, model.InterfaceDependency):
368 if child.qdom.name == 'runner':
369 child_command = command + 1
370 else:
371 child_command = None
372 add_node(iter, iface_cache.get_interface(child.interface), child_command, child.importance == model.Dependency.Essential)
373 else:
374 child_iter = self.model.append(parent)
375 self.model[child_iter][InterfaceBrowser.INTERFACE_NAME] = '?'
376 self.model[child_iter][InterfaceBrowser.SUMMARY] = \
377 _('Unknown dependency type : %s') % child
378 self.model[child_iter][InterfaceBrowser.ICON] = self.default_icon
379 else:
380 self.model[iter][InterfaceBrowser.PROBLEM] = essential
381 self.model[iter][InterfaceBrowser.VERSION] = _('(problem)') if essential else _('(none)')
382 if commands:
383 add_node(None, self.root, 0, essential = True)
384 else:
385 # Nothing could be selected, or no command requested
386 add_node(None, self.root, None, essential = True)
387 self.tree_view.expand_all()
389 def show_popup_menu(self, iface, bev):
390 import bugs
392 have_source = properties.have_source_for(self.policy, iface)
394 menu = gtk.Menu()
395 for label, cb in [(_('Show Feeds'), lambda: properties.edit(self.policy, iface, self.compile)),
396 (_('Show Versions'), lambda: properties.edit(self.policy, iface, self.compile, show_versions = True)),
397 (_('Report a Bug...'), lambda: bugs.report_bug(self.policy, iface))]:
398 item = gtk.MenuItem(label)
399 if cb:
400 item.connect('activate', lambda item, cb=cb: cb())
401 else:
402 item.set_sensitive(False)
403 item.show()
404 menu.append(item)
406 item = gtk.MenuItem(_('Compile'))
407 item.show()
408 menu.append(item)
409 if have_source:
410 compile_menu = gtk.Menu()
411 item.set_submenu(compile_menu)
413 item = gtk.MenuItem(_('Automatic'))
414 item.connect('activate', lambda item: self.compile(iface, autocompile = True))
415 item.show()
416 compile_menu.append(item)
418 item = gtk.MenuItem(_('Manual...'))
419 item.connect('activate', lambda item: self.compile(iface, autocompile = False))
420 item.show()
421 compile_menu.append(item)
422 else:
423 item.set_sensitive(False)
425 menu.popup(None, None, None, bev.button, bev.time)
427 def compile(self, interface, autocompile = True):
428 import compile
429 def on_success():
430 # A new local feed may have been registered, so reload it from the disk cache
431 info(_("0compile command completed successfully. Reloading interface details."))
432 reader.update_from_cache(interface)
433 for feed in interface.extra_feeds:
434 self.policy.config.iface_cache.get_feed(feed.uri, force = True)
435 import main
436 main.recalculate()
437 compile.compile(on_success, interface.uri, autocompile = autocompile)
439 def set_original_implementations(self):
440 assert self.original_implementation is None
441 self.original_implementation = self.policy.implementation.copy()
443 def update_download_status(self, only_update_visible = False):
444 """Called at regular intervals while there are downloads in progress,
445 and once at the end. Also called when things are added to the store.
446 Update the TreeView with the interfaces."""
448 # A download may be for a feed, an interface or an implementation.
449 # Create the reverse mapping (item -> download)
450 hints = {}
451 for dl in self.policy.handler.monitored_downloads.values():
452 if dl.hint:
453 if dl.hint not in hints:
454 hints[dl.hint] = []
455 hints[dl.hint].append(dl)
457 selections = self.policy.solver.selections
459 # Only update currently visible rows
460 if only_update_visible and self.tree_view.get_visible_range() != None:
461 firstVisiblePath, lastVisiblePath = self.tree_view.get_visible_range()
462 firstVisibleIter = self.model.get_iter(firstVisiblePath)
463 else:
464 # (or should we just wait until the TreeView has settled enough to tell
465 # us what is visible?)
466 firstVisibleIter = self.model.get_iter_root()
467 lastVisiblePath = None
469 for it in walk(self.model, firstVisibleIter):
470 row = self.model[it]
471 iface = row[InterfaceBrowser.INTERFACE]
473 # Is this interface the download's hint?
474 downloads = hints.get(iface, []) # The interface itself
475 downloads += hints.get(iface.uri, []) # The main feed
476 for feed in self.policy.usable_feeds(iface):
477 downloads += hints.get(feed.uri, []) # Other feeds
478 impl = selections.get(iface, None)
479 if impl:
480 downloads += hints.get(impl, []) # The chosen implementation
482 if downloads:
483 so_far = 0
484 expected = None
485 for dl in downloads:
486 if dl.expected_size:
487 expected = (expected or 0) + dl.expected_size
488 so_far += dl.get_bytes_downloaded_so_far()
489 if expected:
490 summary = ngettext("(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%])",
491 "(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%] in %(number)d downloads)",
492 downloads)
493 values_dict = {'downloaded': pretty_size(so_far), 'expected': pretty_size(expected), 'percentage': 100 * so_far / float(expected), 'number': len(downloads)}
494 else:
495 summary = ngettext("(downloading %(downloaded)s/unknown)",
496 "(downloading %(downloaded)s/unknown in %(number)d downloads)",
497 downloads)
498 values_dict = {'downloaded': pretty_size(so_far), 'number': len(downloads)}
499 row[InterfaceBrowser.SUMMARY] = summary % values_dict
500 else:
501 row[InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.policy, impl)
502 row[InterfaceBrowser.SUMMARY] = iface.summary
504 if self.model.get_path(it) == lastVisiblePath:
505 break
507 def highlight_problems(self):
508 """Called when the solve finishes. Highlight any missing implementations."""
509 for it in walk(self.model, self.model.get_iter_root()):
510 row = self.model[it]
511 iface = row[InterfaceBrowser.INTERFACE]
512 sel = self.policy.solver.selections.selections.get(iface.uri, None)
514 if sel is None and row[InterfaceBrowser.PROBLEM]:
515 row[InterfaceBrowser.BACKGROUND] = '#f88'