Added <restricts> element
[zeroinstall/solver.git] / zeroinstall / 0launch-gui / iface_browser.py
blob4fd07e4d5db7bafe8aa5f42e136059ecc547e360
1 # Copyright (C) 2009, Thomas Leonard
2 # See the README file for details, or visit http://0install.net.
4 from zeroinstall import gobject
5 import gtk, pango
7 from zeroinstall import _, translation
8 from zeroinstall.support import tasks, pretty_size
9 from zeroinstall.injector import model, reader
10 import properties
11 from zeroinstall.gtkui.icon import load_icon
12 from zeroinstall import support
13 from logging import warn, info
14 import utils
16 ngettext = translation.ngettext
18 def _stability(impl):
19 assert impl
20 if impl.user_stability is None:
21 return _(str(impl.upstream_stability))
22 return _("%(implementation_user_stability)s (was %(implementation_upstream_stability)s)") \
23 % {'implementation_user_stability': _(str(impl.user_stability)),
24 'implementation_upstream_stability': _(str(impl.upstream_stability))}
26 ICON_SIZE = 20.0
27 CELL_TEXT_INDENT = int(ICON_SIZE) + 4
29 def get_tooltip_text(mainwindow, interface, main_feed, model_column):
30 assert interface
31 if model_column == InterfaceBrowser.INTERFACE_NAME:
32 return _("Full name: %s") % interface.uri
33 elif model_column == InterfaceBrowser.SUMMARY:
34 if main_feed is None or not main_feed.description:
35 return _("(no description available)")
36 first_para = main_feed.description.split('\n\n', 1)[0]
37 return first_para.replace('\n', ' ')
38 elif model_column is None:
39 return _("Click here for more options...")
41 impl = mainwindow.driver.solver.selections.get(interface, None)
42 if not impl:
43 return _("No suitable version was found. Double-click "
44 "here to find out why.")
46 if model_column == InterfaceBrowser.VERSION:
47 text = _("Currently preferred version: %(version)s (%(stability)s)") % \
48 {'version': impl.get_version(), 'stability': _stability(impl)}
49 old_impl = mainwindow.original_implementation.get(interface, None)
50 if old_impl is not None and old_impl is not impl:
51 text += '\n' + _('Previously preferred version: %(version)s (%(stability)s)') % \
52 {'version': old_impl.get_version(), 'stability': _stability(old_impl)}
53 return text
55 assert model_column == InterfaceBrowser.DOWNLOAD_SIZE
57 if impl.is_available(mainwindow.driver.config.stores):
58 return _("This version is already stored on your computer.")
59 else:
60 src = mainwindow.driver.config.fetcher.get_best_source(impl)
61 if not src:
62 return _("No downloads available!")
63 return _("Need to download %(pretty_size)s (%(size)s bytes)") % \
64 {'pretty_size': support.pretty_size(src.size), 'size': src.size}
66 import math
67 angle_right = math.pi / 2
68 class MenuIconRenderer(gtk.GenericCellRenderer):
69 def __init__(self):
70 gtk.GenericCellRenderer.__init__(self)
71 self.set_property('mode', gtk.CELL_RENDERER_MODE_ACTIVATABLE)
73 def do_set_property(self, prop, value):
74 setattr(self, prop.name, value)
76 def do_get_size(self, widget, cell_area, layout = None):
77 return (0, 0, 20, 20)
78 on_get_size = do_get_size # GTK 2
80 if gtk.pygtk_version >= (2, 90):
81 # note: if you get "TypeError: Couldn't find conversion for foreign struct 'cairo.Context'", you need "python3-gi-cairo"
82 def do_render(self, cr, widget, background_area, cell_area, flags): # GTK 3
83 context = widget.get_style_context()
84 gtk.render_arrow(context, cr, angle_right,
85 cell_area.x + 5, cell_area.y + 5, max(cell_area.width, cell_area.height) - 10)
86 else:
87 def on_render(self, window, widget, background_area, cell_area, expose_area, flags): # GTK 2
88 if flags & gtk.CELL_RENDERER_PRELIT:
89 state = gtk.STATE_PRELIGHT
90 else:
91 state = gtk.STATE_NORMAL
93 widget.style.paint_box(window, state, gtk.SHADOW_OUT, expose_area, widget, None,
94 cell_area.x, cell_area.y, cell_area.width, cell_area.height)
95 widget.style.paint_arrow(window, state, gtk.SHADOW_NONE, expose_area, widget, None,
96 gtk.ARROW_RIGHT, True,
97 cell_area.x + 5, cell_area.y + 5, cell_area.width - 10, cell_area.height - 10)
99 class IconAndTextRenderer(gtk.GenericCellRenderer):
100 __gproperties__ = {
101 "image": (gobject.TYPE_PYOBJECT, "Image", "Image", gobject.PARAM_READWRITE),
102 "text": (gobject.TYPE_STRING, "Text", "Text", "-", gobject.PARAM_READWRITE),
105 def do_set_property(self, prop, value):
106 setattr(self, prop.name, value)
108 def do_get_size(self, widget, cell_area, layout = None):
109 if not layout:
110 layout = widget.create_pango_layout(self.text)
111 a, rect = layout.get_pixel_extents()
113 if self.image:
114 pixmap_height = self.image.get_height()
115 else:
116 pixmap_height = 32
118 if not isinstance(rect, tuple):
119 rect = (rect.x, rect.y, rect.width, rect.height) # GTK 3
121 both_height = max(rect[1] + rect[3], pixmap_height)
123 return (0, 0,
124 rect[0] + rect[2] + CELL_TEXT_INDENT,
125 both_height)
126 on_get_size = do_get_size # GTK 2
128 if gtk.pygtk_version >= (2, 90):
129 def do_render(self, cr, widget, background_area, cell_area, flags): # GTK 3
130 layout = widget.create_pango_layout(self.text)
131 a, rect = layout.get_pixel_extents()
132 context = widget.get_style_context()
134 image_y = int(0.5 * (cell_area.height - self.image.get_height()))
135 gtk.render_icon(context, cr, self.image, cell_area.x, cell_area.y)
137 text_y = int(0.5 * (cell_area.height - (rect.y + rect.height)))
139 gtk.render_layout(context, cr,
140 cell_area.x + CELL_TEXT_INDENT,
141 cell_area.y + text_y,
142 layout)
143 else:
144 def on_render(self, window, widget, background_area, cell_area, expose_area, flags): # GTK 2
145 layout = widget.create_pango_layout(self.text)
146 a, rect = layout.get_pixel_extents()
148 if flags & gtk.CELL_RENDERER_SELECTED:
149 state = gtk.STATE_SELECTED
150 elif flags & gtk.CELL_RENDERER_PRELIT:
151 state = gtk.STATE_PRELIGHT
152 else:
153 state = gtk.STATE_NORMAL
155 image_y = int(0.5 * (cell_area.height - self.image.get_height()))
156 window.draw_pixbuf(widget.style.white_gc, self.image, 0, 0,
157 cell_area.x,
158 cell_area.y + image_y)
160 text_y = int(0.5 * (cell_area.height - (rect[1] + rect[3])))
162 widget.style.paint_layout(window, state, True,
163 expose_area, widget, "cellrenderertext",
164 cell_area.x + CELL_TEXT_INDENT,
165 cell_area.y + text_y,
166 layout)
168 if gtk.pygtk_version < (2, 8, 0):
169 # Note sure exactly which versions need this.
170 # 2.8.0 gives a warning if you include it, though.
171 gobject.type_register(IconAndTextRenderer)
172 gobject.type_register(MenuIconRenderer)
174 def walk(model, it):
175 while it:
176 yield it
177 for x in walk(model, model.iter_children(it)): yield x
178 it = model.iter_next(it)
180 class InterfaceBrowser:
181 model = None
182 root = None
183 cached_icon = None
184 driver = None
185 config = None
186 original_implementation = None
187 update_icons = False
189 INTERFACE = 0
190 INTERFACE_NAME = 1
191 VERSION = 2
192 SUMMARY = 3
193 DOWNLOAD_SIZE = 4
194 ICON = 5
195 BACKGROUND = 6
196 PROBLEM = 7
198 columns = [(_('Component'), INTERFACE_NAME),
199 (_('Version'), VERSION),
200 (_('Fetch'), DOWNLOAD_SIZE),
201 (_('Description'), SUMMARY),
202 ('', None)]
204 def __init__(self, driver, widgets):
205 self.driver = driver
206 self.config = driver.config
208 tree_view = widgets.get_widget('components')
209 tree_view.set_property('has-tooltip', True)
210 def callback(widget, x, y, keyboard_mode, tooltip):
211 x, y = tree_view.convert_widget_to_bin_window_coords(x, y)
212 pos = tree_view.get_path_at_pos(x, y)
213 if pos:
214 tree_view.set_tooltip_cell(tooltip, pos[0], pos[1], None)
215 path = pos[0]
216 try:
217 col_index = column_objects.index(pos[1])
218 except ValueError:
219 return False
220 else:
221 col = self.columns[col_index][1]
222 row = self.model[path]
223 iface = row[InterfaceBrowser.INTERFACE]
224 main_feed = self.config.iface_cache.get_feed(iface.uri)
225 tooltip.set_text(get_tooltip_text(self, iface, main_feed, col))
226 return True
227 else:
228 return False
229 tree_view.connect('query-tooltip', callback)
231 self.cached_icon = {} # URI -> GdkPixbuf
232 self.default_icon = tree_view.get_style().lookup_icon_set(gtk.STOCK_EXECUTE).render_icon(tree_view.get_style(),
233 gtk.TEXT_DIR_NONE, gtk.STATE_NORMAL, gtk.ICON_SIZE_SMALL_TOOLBAR, tree_view, None)
235 self.model = gtk.TreeStore(object, str, str, str, str, gobject.TYPE_PYOBJECT, str, bool)
236 self.tree_view = tree_view
237 tree_view.set_model(self.model)
239 column_objects = []
241 text = gtk.CellRendererText()
242 coloured_text = gtk.CellRendererText()
244 for name, model_column in self.columns:
245 if model_column == InterfaceBrowser.INTERFACE_NAME:
246 column = gtk.TreeViewColumn(name, IconAndTextRenderer(),
247 text = model_column,
248 image = InterfaceBrowser.ICON)
249 elif model_column == None:
250 menu_column = column = gtk.TreeViewColumn('', MenuIconRenderer())
251 else:
252 if model_column == InterfaceBrowser.SUMMARY:
253 text_ellip = gtk.CellRendererText()
254 try:
255 text_ellip.set_property('ellipsize', pango.ELLIPSIZE_END)
256 except:
257 pass
258 column = gtk.TreeViewColumn(name, text_ellip, text = model_column)
259 column.set_expand(True)
260 elif model_column == InterfaceBrowser.VERSION:
261 column = gtk.TreeViewColumn(name, coloured_text, text = model_column,
262 background = InterfaceBrowser.BACKGROUND)
263 else:
264 column = gtk.TreeViewColumn(name, text, text = model_column)
265 tree_view.append_column(column)
266 column_objects.append(column)
268 tree_view.set_enable_search(True)
270 selection = tree_view.get_selection()
272 def button_press(tree_view, bev):
273 pos = tree_view.get_path_at_pos(int(bev.x), int(bev.y))
274 if not pos:
275 return False
276 path, col, x, y = pos
278 if (bev.button == 3 or (bev.button < 4 and col is menu_column)) \
279 and bev.type == gtk.gdk.BUTTON_PRESS:
280 selection.select_path(path)
281 iface = self.model[path][InterfaceBrowser.INTERFACE]
282 self.show_popup_menu(iface, bev)
283 return True
284 if bev.button != 1 or bev.type != gtk.gdk._2BUTTON_PRESS:
285 return False
286 properties.edit(driver, self.model[path][InterfaceBrowser.INTERFACE], self.compile, show_versions = True)
287 tree_view.connect('button-press-event', button_press)
289 tree_view.connect('destroy', lambda s: driver.watchers.remove(self.build_tree))
290 driver.watchers.append(self.build_tree)
292 def set_root(self, root):
293 assert isinstance(root, model.Interface)
294 self.root = root
296 def set_update_icons(self, update_icons):
297 if update_icons:
298 # Clear icons cache to make sure they're really updated
299 self.cached_icon = {}
300 self.update_icons = update_icons
302 def get_icon(self, iface):
303 """Get an icon for this interface. If the icon is in the cache, use that.
304 If not, start a download. If we already started a download (successful or
305 not) do nothing. Returns None if no icon is currently available."""
306 try:
307 # Try the in-memory cache
308 return self.cached_icon[iface.uri]
309 except KeyError:
310 # Try the on-disk cache
311 iconpath = self.config.iface_cache.get_icon_path(iface)
313 if iconpath:
314 icon = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
315 # (if icon is None, cache the fact that we can't load it)
316 self.cached_icon[iface.uri] = icon
317 else:
318 icon = None
320 # Download a new icon if we don't have one, or if the
321 # user did a 'Refresh'
322 if iconpath is None or self.update_icons:
323 if self.config.network_use == model.network_offline:
324 fetcher = None
325 else:
326 fetcher = self.config.fetcher.download_icon(iface)
327 if fetcher:
328 if iface.uri not in self.cached_icon:
329 self.cached_icon[iface.uri] = None # Only try once
331 @tasks.async
332 def update_display():
333 yield fetcher
334 try:
335 tasks.check(fetcher)
336 # Try to insert new icon into the cache
337 # If it fails, we'll be left with None in the cached_icon so
338 # we don't try again.
339 iconpath = self.config.iface_cache.get_icon_path(iface)
340 if iconpath:
341 self.cached_icon[iface.uri] = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
342 self.build_tree()
343 else:
344 warn("Failed to download icon for '%s'", iface)
345 except Exception as ex:
346 import traceback
347 traceback.print_exc()
348 self.config.handler.report_error(ex)
349 update_display()
350 # elif fetcher is None: don't store anything in cached_icon
352 # Note: if no icon is available for downloading,
353 # more attempts are made later.
354 # It can happen that no icon is yet available because
355 # the interface was not downloaded yet, in which case
356 # it's desireable to try again once the interface is available
357 return icon
359 return None
361 def build_tree(self):
362 iface_cache = self.config.iface_cache
364 if self.original_implementation is None:
365 self.set_original_implementations()
367 done = {} # Detect cycles
369 sels = self.driver.solver.selections
371 self.model.clear()
372 def add_node(parent, iface, commands, essential):
373 if iface in done:
374 return
375 done[iface] = True
377 main_feed = iface_cache.get_feed(iface.uri)
378 if main_feed:
379 name = main_feed.get_name()
380 summary = main_feed.summary
381 else:
382 name = iface.get_name()
383 summary = None
385 iter = self.model.append(parent)
386 self.model[iter][InterfaceBrowser.INTERFACE] = iface
387 self.model[iter][InterfaceBrowser.INTERFACE_NAME] = name
388 self.model[iter][InterfaceBrowser.SUMMARY] = summary or ''
389 self.model[iter][InterfaceBrowser.ICON] = self.get_icon(iface) or self.default_icon
390 self.model[iter][InterfaceBrowser.PROBLEM] = False
392 sel = sels.selections.get(iface.uri, None)
393 if sel:
394 impl = sel.impl
395 old_impl = self.original_implementation.get(iface, None)
396 version_str = impl.get_version()
397 if old_impl is not None and old_impl.id != impl.id:
398 version_str += _(' (was %s)') % old_impl.get_version()
399 self.model[iter][InterfaceBrowser.VERSION] = version_str
401 self.model[iter][InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.config, impl)
403 deps = sel.dependencies
404 for c in commands:
405 deps += sel.get_command(c).requires
406 for child in deps:
407 if isinstance(child, model.InterfaceDependency):
408 add_node(iter,
409 iface_cache.get_interface(child.interface),
410 child.get_required_commands(),
411 child.importance == model.Dependency.Essential)
412 elif not isinstance(child, model.InterfaceRestriction):
413 child_iter = self.model.append(parent)
414 self.model[child_iter][InterfaceBrowser.INTERFACE_NAME] = '?'
415 self.model[child_iter][InterfaceBrowser.SUMMARY] = \
416 _('Unknown dependency type : %s') % child
417 self.model[child_iter][InterfaceBrowser.ICON] = self.default_icon
418 else:
419 self.model[iter][InterfaceBrowser.PROBLEM] = essential
420 self.model[iter][InterfaceBrowser.VERSION] = _('(problem)') if essential else _('(none)')
421 try:
422 if sels.command:
423 add_node(None, self.root, [sels.command], essential = True)
424 else:
425 add_node(None, self.root, [], essential = True)
426 self.tree_view.expand_all()
427 except Exception as ex:
428 warn("Failed to build tree: %s", ex, exc_info = ex)
429 raise
431 def show_popup_menu(self, iface, bev):
432 import bugs
434 have_source = properties.have_source_for(self.config, iface)
436 global menu # Fix GC problem in PyGObject
437 menu = gtk.Menu()
438 for label, cb in [(_('Show Feeds'), lambda: properties.edit(self.driver, iface, self.compile)),
439 (_('Show Versions'), lambda: properties.edit(self.driver, iface, self.compile, show_versions = True)),
440 (_('Report a Bug...'), lambda: bugs.report_bug(self.driver, iface))]:
441 item = gtk.MenuItem()
442 item.set_label(label)
443 if cb:
444 item.connect('activate', lambda item, cb=cb: cb())
445 else:
446 item.set_sensitive(False)
447 item.show()
448 menu.append(item)
450 item = gtk.MenuItem()
451 item.set_label(_('Compile'))
452 item.show()
453 menu.append(item)
454 if have_source:
455 compile_menu = gtk.Menu()
456 item.set_submenu(compile_menu)
458 item = gtk.MenuItem()
459 item.set_label(_('Automatic'))
460 item.connect('activate', lambda item: self.compile(iface, autocompile = True))
461 item.show()
462 compile_menu.append(item)
464 item = gtk.MenuItem()
465 item.set_label(_('Manual...'))
466 item.connect('activate', lambda item: self.compile(iface, autocompile = False))
467 item.show()
468 compile_menu.append(item)
469 else:
470 item.set_sensitive(False)
472 if gtk.pygtk_version >= (2, 90):
473 menu.popup(None, None, None, None, bev.button, bev.time)
474 else:
475 menu.popup(None, None, None, bev.button, bev.time)
477 def compile(self, interface, autocompile = True):
478 import compile
479 def on_success():
480 # A new local feed may have been registered, so reload it from the disk cache
481 info(_("0compile command completed successfully. Reloading interface details."))
482 reader.update_from_cache(interface)
483 for feed in interface.extra_feeds:
484 self.config.iface_cache.get_feed(feed.uri, force = True)
485 import main
486 main.recalculate()
487 compile.compile(on_success, interface.uri, autocompile = autocompile)
489 def set_original_implementations(self):
490 assert self.original_implementation is None
491 self.original_implementation = self.driver.solver.selections.copy()
493 def update_download_status(self, only_update_visible = False):
494 """Called at regular intervals while there are downloads in progress,
495 and once at the end. Also called when things are added to the store.
496 Update the TreeView with the interfaces."""
498 # A download may be for a feed, an interface or an implementation.
499 # Create the reverse mapping (item -> download)
500 hints = {}
501 for dl in self.config.handler.monitored_downloads:
502 if dl.hint:
503 if dl.hint not in hints:
504 hints[dl.hint] = []
505 hints[dl.hint].append(dl)
507 selections = self.driver.solver.selections
509 # Only update currently visible rows
510 if only_update_visible and self.tree_view.get_visible_range() != None:
511 firstVisiblePath, lastVisiblePath = self.tree_view.get_visible_range()
512 firstVisibleIter = self.model.get_iter(firstVisiblePath)
513 else:
514 # (or should we just wait until the TreeView has settled enough to tell
515 # us what is visible?)
516 firstVisibleIter = self.model.get_iter_root()
517 lastVisiblePath = None
519 solver = self.driver.solver
520 requirements = self.driver.requirements
521 iface_cache = self.config.iface_cache
523 for it in walk(self.model, firstVisibleIter):
524 row = self.model[it]
525 iface = row[InterfaceBrowser.INTERFACE]
527 # Is this interface the download's hint?
528 downloads = hints.get(iface, []) # The interface itself
529 downloads += hints.get(iface.uri, []) # The main feed
531 arch = solver.get_arch_for(requirements, iface)
532 for feed in iface_cache.usable_feeds(iface, arch):
533 downloads += hints.get(feed.uri, []) # Other feeds
534 impl = selections.get(iface, None)
535 if impl:
536 downloads += hints.get(impl, []) # The chosen implementation
538 if downloads:
539 so_far = 0
540 expected = None
541 for dl in downloads:
542 if dl.expected_size:
543 expected = (expected or 0) + dl.expected_size
544 so_far += dl.get_bytes_downloaded_so_far()
545 if expected:
546 summary = ngettext("(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%])",
547 "(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%] in %(number)d downloads)",
548 downloads)
549 values_dict = {'downloaded': pretty_size(so_far), 'expected': pretty_size(expected), 'percentage': 100 * so_far / float(expected), 'number': len(downloads)}
550 else:
551 summary = ngettext("(downloading %(downloaded)s/unknown)",
552 "(downloading %(downloaded)s/unknown in %(number)d downloads)",
553 downloads)
554 values_dict = {'downloaded': pretty_size(so_far), 'number': len(downloads)}
555 row[InterfaceBrowser.SUMMARY] = summary % values_dict
556 else:
557 row[InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.config, impl)
558 row[InterfaceBrowser.SUMMARY] = iface.summary
560 if self.model.get_path(it) == lastVisiblePath:
561 break
563 def highlight_problems(self):
564 """Called when the solve finishes. Highlight any missing implementations."""
565 for it in walk(self.model, self.model.get_iter_root()):
566 row = self.model[it]
567 iface = row[InterfaceBrowser.INTERFACE]
568 sel = self.driver.solver.selections.selections.get(iface.uri, None)
570 if sel is None and row[InterfaceBrowser.PROBLEM]:
571 row[InterfaceBrowser.BACKGROUND] = '#f88'