Use new GTK tooltips API
[zeroinstall/zeroinstall-afb.git] / zeroinstall / 0launch-gui / iface_browser.py
blob9589d88d52240c91e981242aa550d9ed8dcbeb42
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.support import tasks, pretty_size
7 from zeroinstall.injector.iface_cache import iface_cache
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 def _stability(impl):
16 assert impl
17 if impl.user_stability is None:
18 return _(str(impl.upstream_stability))
19 return _("%(implementation_user_stability)s (was %(implementation_upstream_stability)s)") \
20 % {'implementation_user_stability': _(str(impl.user_stability)),
21 'implementation_upstream_stability': _(str(impl.upstream_stability))}
23 ICON_SIZE = 20.0
24 CELL_TEXT_INDENT = int(ICON_SIZE) + 4
26 def get_tooltip_text(mainwindow, interface, model_column):
27 assert interface
28 if model_column == InterfaceBrowser.INTERFACE_NAME:
29 return _("Full name: %s") % interface.uri
30 elif model_column == InterfaceBrowser.SUMMARY:
31 if not interface.description:
32 return _("(no description available)")
33 first_para = interface.description.split('\n\n', 1)[0]
34 return first_para.replace('\n', ' ')
35 elif model_column is None:
36 return _("Click here for more options...")
38 impl = mainwindow.policy.implementation.get(interface, None)
39 if not impl:
40 return _("No suitable implementation was found. Check the "
41 "interface properties to find out why.")
43 if model_column == InterfaceBrowser.VERSION:
44 text = _("Currently preferred version: %(version)s (%(stability)s)") % \
45 {'version': impl.get_version(), 'stability': _stability(impl)}
46 old_impl = mainwindow.original_implementation.get(interface, None)
47 if old_impl is not None and old_impl is not impl:
48 text += '\n' + _('Previously preferred version: %(version)s (%(stability)s)') % \
49 {'version': old_impl.get_version(), 'stability': _stability(old_impl)}
50 return text
52 assert model_column == InterfaceBrowser.DOWNLOAD_SIZE
54 if mainwindow.policy.get_cached(impl):
55 return _("This version is already stored on your computer.")
56 else:
57 src = mainwindow.policy.fetcher.get_best_source(impl)
58 if not src:
59 return _("No downloads available!")
60 return _("Need to download %(pretty_size)s (%(size)s bytes)") % \
61 {'pretty_size': support.pretty_size(src.size), 'size': src.size}
63 class MenuIconRenderer(gtk.GenericCellRenderer):
64 def __init__(self):
65 gtk.GenericCellRenderer.__init__(self)
66 self.set_property('mode', gtk.CELL_RENDERER_MODE_ACTIVATABLE)
68 def do_set_property(self, prop, value):
69 setattr(self, prop.name, value)
71 def on_get_size(self, widget, cell_area, layout = None):
72 return (0, 0, 20, 20)
74 def on_render(self, window, widget, background_area, cell_area, expose_area, flags):
75 if flags & gtk.CELL_RENDERER_PRELIT:
76 state = gtk.STATE_PRELIGHT
77 else:
78 state = gtk.STATE_NORMAL
80 widget.style.paint_box(window, state, gtk.SHADOW_OUT, expose_area, widget, None,
81 cell_area.x, cell_area.y, cell_area.width, cell_area.height)
82 widget.style.paint_arrow(window, state, gtk.SHADOW_NONE, expose_area, widget, None,
83 gtk.ARROW_RIGHT, True,
84 cell_area.x + 5, cell_area.y + 5, cell_area.width - 10, cell_area.height - 10)
86 class IconAndTextRenderer(gtk.GenericCellRenderer):
87 __gproperties__ = {
88 "image": (gobject.TYPE_OBJECT, "Image", "Image", gobject.PARAM_READWRITE),
89 "text": (gobject.TYPE_STRING, "Text", "Text", "-", gobject.PARAM_READWRITE),
92 def do_set_property(self, prop, value):
93 setattr(self, prop.name, value)
95 def on_get_size(self, widget, cell_area, layout = None):
96 if not layout:
97 layout = widget.create_pango_layout(self.text)
98 a, rect = layout.get_pixel_extents()
100 pixmap_height = self.image.get_height()
102 both_height = max(rect[1] + rect[3], pixmap_height)
104 return (0, 0,
105 rect[0] + rect[2] + CELL_TEXT_INDENT,
106 both_height)
108 def on_render(self, window, widget, background_area, cell_area, expose_area, flags):
109 layout = widget.create_pango_layout(self.text)
110 a, rect = layout.get_pixel_extents()
112 if flags & gtk.CELL_RENDERER_SELECTED:
113 state = gtk.STATE_SELECTED
114 elif flags & gtk.CELL_RENDERER_PRELIT:
115 state = gtk.STATE_PRELIGHT
116 else:
117 state = gtk.STATE_NORMAL
119 image_y = int(0.5 * (cell_area.height - self.image.get_height()))
120 window.draw_pixbuf(widget.style.white_gc, self.image, 0, 0,
121 cell_area.x,
122 cell_area.y + image_y)
124 text_y = int(0.5 * (cell_area.height - (rect[1] + rect[3])))
126 widget.style.paint_layout(window, state, True,
127 expose_area, widget, "cellrenderertext",
128 cell_area.x + CELL_TEXT_INDENT,
129 cell_area.y + text_y,
130 layout)
132 if gtk.pygtk_version < (2, 8, 0):
133 # Note sure exactly which versions need this.
134 # 2.8.0 gives a warning if you include it, though.
135 gobject.type_register(IconAndTextRenderer)
136 gobject.type_register(MenuIconRenderer)
138 class InterfaceBrowser:
139 model = None
140 root = None
141 cached_icon = None
142 policy = None
143 original_implementation = None
144 update_icons = False
146 INTERFACE = 0
147 INTERFACE_NAME = 1
148 VERSION = 2
149 SUMMARY = 3
150 DOWNLOAD_SIZE = 4
151 ICON = 5
153 columns = [(_('Component'), INTERFACE_NAME),
154 (_('Version'), VERSION),
155 (_('Fetch'), DOWNLOAD_SIZE),
156 (_('Description'), SUMMARY),
157 ('', None)]
159 def __init__(self, policy, widgets):
160 tree_view = widgets.get_widget('components')
161 tree_view.set_property('has-tooltip', True)
162 def callback(widget, x, y, keyboard_mode, tooltip):
163 x, y = tree_view.convert_widget_to_bin_window_coords(x, y)
164 pos = tree_view.get_path_at_pos(x, y)
165 if pos:
166 tree_view.set_tooltip_cell(tooltip, pos[0], pos[1], None)
167 path = pos[0]
168 try:
169 col_index = column_objects.index(pos[1])
170 except ValueError:
171 return False
172 else:
173 col = self.columns[col_index][1]
174 row = self.model[path]
175 tooltip.set_text(get_tooltip_text(self, row[InterfaceBrowser.INTERFACE], col))
176 return True
177 else:
178 return False
179 tree_view.connect('query-tooltip', callback)
181 self.policy = policy
182 self.cached_icon = {} # URI -> GdkPixbuf
183 self.default_icon = tree_view.style.lookup_icon_set(gtk.STOCK_EXECUTE).render_icon(tree_view.style,
184 gtk.TEXT_DIR_NONE, gtk.STATE_NORMAL, gtk.ICON_SIZE_SMALL_TOOLBAR, tree_view, None)
186 self.model = gtk.TreeStore(object, str, str, str, str, gtk.gdk.Pixbuf)
187 self.tree_view = tree_view
188 tree_view.set_model(self.model)
190 column_objects = []
192 text = gtk.CellRendererText()
194 for name, model_column in self.columns:
195 if model_column == InterfaceBrowser.INTERFACE_NAME:
196 column = gtk.TreeViewColumn(name, IconAndTextRenderer(),
197 text = model_column,
198 image = InterfaceBrowser.ICON)
199 elif model_column == None:
200 menu_column = column = gtk.TreeViewColumn('', MenuIconRenderer())
201 else:
202 if model_column == InterfaceBrowser.SUMMARY:
203 text_ellip = gtk.CellRendererText()
204 try:
205 text_ellip.set_property('ellipsize', pango.ELLIPSIZE_END)
206 except:
207 pass
208 column = gtk.TreeViewColumn(name, text_ellip, text = model_column)
209 column.set_expand(True)
210 else:
211 column = gtk.TreeViewColumn(name, text, text = model_column)
212 tree_view.append_column(column)
213 column_objects.append(column)
215 tree_view.set_enable_search(True)
217 selection = tree_view.get_selection()
219 def button_press(tree_view, bev):
220 pos = tree_view.get_path_at_pos(int(bev.x), int(bev.y))
221 if not pos:
222 return False
223 path, col, x, y = pos
225 if (bev.button == 3 or (bev.button < 4 and col is menu_column)) \
226 and bev.type == gtk.gdk.BUTTON_PRESS:
227 selection.select_path(path)
228 iface = self.model[path][InterfaceBrowser.INTERFACE]
229 self.show_popup_menu(iface, bev)
230 return True
231 if bev.button != 1 or bev.type != gtk.gdk._2BUTTON_PRESS:
232 return False
233 properties.edit(policy, self.model[path][InterfaceBrowser.INTERFACE], self.compile)
234 tree_view.connect('button-press-event', button_press)
236 tree_view.connect('destroy', lambda s: policy.watchers.remove(self.build_tree))
237 policy.watchers.append(self.build_tree)
239 def set_root(self, root):
240 assert isinstance(root, model.Interface)
241 self.root = root
243 def set_update_icons(self, update_icons):
244 if update_icons:
245 # Clear icons cache to make sure they're really updated
246 self.cached_icon = {}
247 self.update_icons = update_icons
249 def get_icon(self, iface):
250 """Get an icon for this interface. If the icon is in the cache, use that.
251 If not, start a download. If we already started a download (successful or
252 not) do nothing. Returns None if no icon is currently available."""
253 try:
254 # Try the in-memory cache
255 return self.cached_icon[iface.uri]
256 except KeyError:
257 # Try the on-disk cache
258 iconpath = iface_cache.get_icon_path(iface)
260 if iconpath:
261 icon = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
262 # (if icon is None, cache the fact that we can't load it)
263 self.cached_icon[iface.uri] = icon
264 else:
265 icon = None
267 # Download a new icon if we don't have one, or if the
268 # user did a 'Refresh'
269 if iconpath is None or self.update_icons:
270 fetcher = self.policy.download_icon(iface)
271 if fetcher:
272 if iface.uri not in self.cached_icon:
273 self.cached_icon[iface.uri] = None # Only try once
275 @tasks.async
276 def update_display():
277 yield fetcher
278 try:
279 tasks.check(fetcher)
280 # Try to insert new icon into the cache
281 # If it fails, we'll be left with None in the cached_icon so
282 # we don't try again.
283 iconpath = iface_cache.get_icon_path(iface)
284 if iconpath:
285 self.cached_icon[iface.uri] = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
286 self.build_tree()
287 else:
288 warn("Failed to download icon for '%s'", iface)
289 except Exception, ex:
290 import traceback
291 traceback.print_exc()
292 self.policy.handler.report_error(ex)
293 update_display()
294 # elif fetcher is None: don't store anything in cached_icon
296 # Note: if no icon is available for downloading,
297 # more attempts are made later.
298 # It can happen that no icon is yet available because
299 # the interface was not downloaded yet, in which case
300 # it's desireable to try again once the interface is available
301 return icon
303 return None
305 def build_tree(self):
306 if self.original_implementation is None:
307 self.set_original_implementations()
309 done = {} # Detect cycles
311 self.model.clear()
312 parent = None
313 commands = self.policy.solver.selections.commands
314 def add_node(parent, iface, command):
315 # (command is the index into commands, if any)
316 if iface in done:
317 return
318 done[iface] = True
320 iter = self.model.append(parent)
321 self.model[iter][InterfaceBrowser.INTERFACE] = iface
322 self.model[iter][InterfaceBrowser.INTERFACE_NAME] = iface.get_name()
323 self.model[iter][InterfaceBrowser.SUMMARY] = iface.summary
324 self.model[iter][InterfaceBrowser.ICON] = self.get_icon(iface) or self.default_icon
326 sel = self.policy.solver.selections.selections.get(iface.uri, None)
327 if sel:
328 impl = sel.impl
329 old_impl = self.original_implementation.get(iface, None)
330 version_str = impl.get_version()
331 if old_impl is not None and old_impl.id != impl.id:
332 version_str += _(' (was %s)') % old_impl.get_version()
333 self.model[iter][InterfaceBrowser.VERSION] = version_str
335 self.model[iter][InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.policy, impl)
337 deps = sel.dependencies
338 if command is not None:
339 deps += commands[command].requires
340 for child in deps:
341 if isinstance(child, model.InterfaceDependency):
342 if child.qdom.name == 'runner':
343 child_command = command + 1
344 else:
345 child_command = None
346 add_node(iter, iface_cache.get_interface(child.interface), child_command)
347 else:
348 child_iter = self.model.append(parent)
349 self.model[child_iter][InterfaceBrowser.INTERFACE_NAME] = '?'
350 self.model[child_iter][InterfaceBrowser.SUMMARY] = \
351 _('Unknown dependency type : %s') % child
352 self.model[child_iter][InterfaceBrowser.ICON] = self.default_icon
353 else:
354 self.model[iter][InterfaceBrowser.VERSION] = _('(choose)')
355 if commands:
356 add_node(None, self.root, 0)
357 else:
358 # Nothing could be selected, or no command requested
359 add_node(None, self.root, None)
360 self.tree_view.expand_all()
362 def show_popup_menu(self, iface, bev):
363 import bugs
365 have_source = properties.have_source_for(self.policy, iface)
367 menu = gtk.Menu()
368 for label, cb in [(_('Show Feeds'), lambda: properties.edit(self.policy, iface, self.compile)),
369 (_('Show Versions'), lambda: properties.edit(self.policy, iface, self.compile, show_versions = True)),
370 (_('Report a Bug...'), lambda: bugs.report_bug(self.policy, iface))]:
371 item = gtk.MenuItem(label)
372 if cb:
373 item.connect('activate', lambda item, cb=cb: cb())
374 else:
375 item.set_sensitive(False)
376 item.show()
377 menu.append(item)
379 item = gtk.MenuItem(_('Compile'))
380 item.show()
381 menu.append(item)
382 if have_source:
383 compile_menu = gtk.Menu()
384 item.set_submenu(compile_menu)
386 item = gtk.MenuItem(_('Automatic'))
387 item.connect('activate', lambda item: self.compile(iface, autocompile = True))
388 item.show()
389 compile_menu.append(item)
391 item = gtk.MenuItem(_('Manual...'))
392 item.connect('activate', lambda item: self.compile(iface, autocompile = False))
393 item.show()
394 compile_menu.append(item)
395 else:
396 item.set_sensitive(False)
398 menu.popup(None, None, None, bev.button, bev.time)
400 def compile(self, interface, autocompile = False):
401 import compile
402 def on_success():
403 # A new local feed may have been registered, so reload it from the disk cache
404 info(_("0compile command completed successfully. Reloading interface details."))
405 reader.update_from_cache(interface)
406 for feed in interface.extra_feeds:
407 iface_cache.get_feed(feed.uri, force = True)
408 self.policy.recalculate()
409 compile.compile(on_success, interface.uri, autocompile = autocompile)
411 def set_original_implementations(self):
412 assert self.original_implementation is None
413 self.original_implementation = self.policy.implementation.copy()
415 def update_download_status(self):
416 """Called at regular intervals while there are downloads in progress,
417 and once at the end. Also called when things are added to the store.
418 Update the TreeView with the interfaces."""
420 # A download may be for a feed, an interface or an implementation.
421 # Create the reverse mapping (item -> download)
422 hints = {}
423 for dl in self.policy.handler.monitored_downloads.values():
424 if dl.hint:
425 if dl.hint not in hints:
426 hints[dl.hint] = []
427 hints[dl.hint].append(dl)
429 selections = self.policy.solver.selections
431 def walk(it):
432 while it:
433 yield self.model[it]
434 for x in walk(self.model.iter_children(it)): yield x
435 it = self.model.iter_next(it)
437 for row in walk(self.model.get_iter_root()):
438 iface = row[InterfaceBrowser.INTERFACE]
440 # Is this interface the download's hint?
441 downloads = hints.get(iface, []) # The interface itself
442 downloads += hints.get(iface.uri, []) # The main feed
443 for feed in self.policy.usable_feeds(iface):
444 downloads += hints.get(feed.uri, []) # Other feeds
445 impl = selections.get(iface, None)
446 if impl:
447 downloads += hints.get(impl, []) # The chosen implementation
449 if downloads:
450 so_far = 0
451 expected = None
452 for dl in downloads:
453 if dl.expected_size:
454 expected = (expected or 0) + dl.expected_size
455 so_far += dl.get_bytes_downloaded_so_far()
456 if expected:
457 summary = ngettext("(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%])",
458 "(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%] in %(number)d downloads)",
459 downloads)
460 values_dict = {'downloaded': pretty_size(so_far), 'expected': pretty_size(expected), 'percentage': 100 * so_far / float(expected), 'number': len(downloads)}
461 else:
462 summary = ngettext("(downloading %(downloaded)s/unknown)",
463 "(downloading %(downloaded)s/unknown in %(number)d downloads)",
464 downloads)
465 values_dict = {'downloaded': pretty_size(so_far), 'number': len(downloads)}
466 row[InterfaceBrowser.SUMMARY] = summary % values_dict
467 else:
468 row[InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.policy, impl)
469 row[InterfaceBrowser.SUMMARY] = iface.summary