The main GUI window and menus now work with GTK 3 / PyGObject
[zeroinstall/solver.git] / zeroinstall / 0launch-gui / iface_browser.py
blob9b917d08a12f7e1c8d94c0ef16d35f63f26540c1
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.driver.solver.selections.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 impl.is_available(mainwindow.driver.config.stores):
57 return _("This version is already stored on your computer.")
58 else:
59 src = mainwindow.driver.config.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 import math
66 angle_right = math.pi / 2
67 class MenuIconRenderer(gtk.GenericCellRenderer):
68 def __init__(self):
69 gtk.GenericCellRenderer.__init__(self)
70 self.set_property('mode', gtk.CELL_RENDERER_MODE_ACTIVATABLE)
72 def do_set_property(self, prop, value):
73 setattr(self, prop.name, value)
75 def do_get_size(self, widget, cell_area, layout = None):
76 return (0, 0, 20, 20)
77 on_get_size = do_get_size # GTK 2
79 if gtk.pygtk_version >= (2, 90):
80 # note: if you get "TypeError: Couldn't find conversion for foreign struct 'cairo.Context'", you need "python3-gi-cairo"
81 def do_render(self, cr, widget, background_area, cell_area, flags): # GTK 3
82 context = widget.get_style_context()
83 gtk.render_arrow(context, cr, angle_right,
84 cell_area.x + 5, cell_area.y + 5, max(cell_area.width, cell_area.height) - 10)
85 else:
86 def on_render(self, window, widget, background_area, cell_area, expose_area, flags): # GTK 2
87 if flags & gtk.CELL_RENDERER_PRELIT:
88 state = gtk.STATE_PRELIGHT
89 else:
90 state = gtk.STATE_NORMAL
92 widget.style.paint_box(window, state, gtk.SHADOW_OUT, expose_area, widget, None,
93 cell_area.x, cell_area.y, cell_area.width, cell_area.height)
94 widget.style.paint_arrow(window, state, gtk.SHADOW_NONE, expose_area, widget, None,
95 gtk.ARROW_RIGHT, True,
96 cell_area.x + 5, cell_area.y + 5, cell_area.width - 10, cell_area.height - 10)
98 class IconAndTextRenderer(gtk.GenericCellRenderer):
99 __gproperties__ = {
100 "image": (gobject.TYPE_PYOBJECT, "Image", "Image", gobject.PARAM_READWRITE),
101 "text": (gobject.TYPE_STRING, "Text", "Text", "-", gobject.PARAM_READWRITE),
104 def do_set_property(self, prop, value):
105 setattr(self, prop.name, value)
107 def do_get_size(self, widget, cell_area, layout = None):
108 if not layout:
109 layout = widget.create_pango_layout(self.text)
110 a, rect = layout.get_pixel_extents()
112 pixmap_height = self.image.get_height()
114 if not isinstance(rect, tuple):
115 rect = (rect.x, rect.y, rect.width, rect.height) # GTK 3
117 both_height = max(rect[1] + rect[3], pixmap_height)
119 return (0, 0,
120 rect[0] + rect[2] + CELL_TEXT_INDENT,
121 both_height)
122 on_get_size = do_get_size # GTK 2
124 if gtk.pygtk_version >= (2, 90):
125 def do_render(self, cr, widget, background_area, cell_area, flags): # GTK 3
126 layout = widget.create_pango_layout(self.text)
127 a, rect = layout.get_pixel_extents()
128 context = widget.get_style_context()
130 image_y = int(0.5 * (cell_area.height - self.image.get_height()))
131 gtk.render_icon(context, cr, self.image, cell_area.x, cell_area.y)
133 text_y = int(0.5 * (cell_area.height - (rect.y + rect.height)))
135 gtk.render_layout(context, cr,
136 cell_area.x + CELL_TEXT_INDENT,
137 cell_area.y + text_y,
138 layout)
139 else:
140 def on_render(self, window, widget, background_area, cell_area, expose_area, flags): # GTK 2
141 layout = widget.create_pango_layout(self.text)
142 a, rect = layout.get_pixel_extents()
144 if flags & gtk.CELL_RENDERER_SELECTED:
145 state = gtk.STATE_SELECTED
146 elif flags & gtk.CELL_RENDERER_PRELIT:
147 state = gtk.STATE_PRELIGHT
148 else:
149 state = gtk.STATE_NORMAL
151 image_y = int(0.5 * (cell_area.height - self.image.get_height()))
152 window.draw_pixbuf(widget.style.white_gc, self.image, 0, 0,
153 cell_area.x,
154 cell_area.y + image_y)
156 text_y = int(0.5 * (cell_area.height - (rect[1] + rect[3])))
158 widget.style.paint_layout(window, state, True,
159 expose_area, widget, "cellrenderertext",
160 cell_area.x + CELL_TEXT_INDENT,
161 cell_area.y + text_y,
162 layout)
164 if gtk.pygtk_version < (2, 8, 0):
165 # Note sure exactly which versions need this.
166 # 2.8.0 gives a warning if you include it, though.
167 gobject.type_register(IconAndTextRenderer)
168 gobject.type_register(MenuIconRenderer)
170 def walk(model, it):
171 while it:
172 yield it
173 for x in walk(model, model.iter_children(it)): yield x
174 it = model.iter_next(it)
176 class InterfaceBrowser:
177 model = None
178 root = None
179 cached_icon = None
180 driver = None
181 config = None
182 original_implementation = None
183 update_icons = False
185 INTERFACE = 0
186 INTERFACE_NAME = 1
187 VERSION = 2
188 SUMMARY = 3
189 DOWNLOAD_SIZE = 4
190 ICON = 5
191 BACKGROUND = 6
192 PROBLEM = 7
194 columns = [(_('Component'), INTERFACE_NAME),
195 (_('Version'), VERSION),
196 (_('Fetch'), DOWNLOAD_SIZE),
197 (_('Description'), SUMMARY),
198 ('', None)]
200 def __init__(self, driver, widgets):
201 self.driver = driver
202 self.config = driver.config
204 tree_view = widgets.get_widget('components')
205 tree_view.set_property('has-tooltip', True)
206 def callback(widget, x, y, keyboard_mode, tooltip):
207 x, y = tree_view.convert_widget_to_bin_window_coords(x, y)
208 pos = tree_view.get_path_at_pos(x, y)
209 if pos:
210 tree_view.set_tooltip_cell(tooltip, pos[0], pos[1], None)
211 path = pos[0]
212 try:
213 col_index = column_objects.index(pos[1])
214 except ValueError:
215 return False
216 else:
217 col = self.columns[col_index][1]
218 row = self.model[path]
219 iface = row[InterfaceBrowser.INTERFACE]
220 main_feed = self.config.iface_cache.get_feed(iface.uri)
221 tooltip.set_text(get_tooltip_text(self, iface, main_feed, col))
222 return True
223 else:
224 return False
225 tree_view.connect('query-tooltip', callback)
227 self.cached_icon = {} # URI -> GdkPixbuf
228 self.default_icon = tree_view.get_style().lookup_icon_set(gtk.STOCK_EXECUTE).render_icon(tree_view.get_style(),
229 gtk.TEXT_DIR_NONE, gtk.STATE_NORMAL, gtk.ICON_SIZE_SMALL_TOOLBAR, tree_view, None)
231 self.model = gtk.TreeStore(object, str, str, str, str, gobject.TYPE_PYOBJECT, str, bool)
232 self.tree_view = tree_view
233 tree_view.set_model(self.model)
235 column_objects = []
237 text = gtk.CellRendererText()
238 coloured_text = gtk.CellRendererText()
240 for name, model_column in self.columns:
241 if model_column == InterfaceBrowser.INTERFACE_NAME:
242 column = gtk.TreeViewColumn(name, IconAndTextRenderer(),
243 text = model_column,
244 image = InterfaceBrowser.ICON)
245 elif model_column == None:
246 menu_column = column = gtk.TreeViewColumn('', MenuIconRenderer())
247 else:
248 if model_column == InterfaceBrowser.SUMMARY:
249 text_ellip = gtk.CellRendererText()
250 try:
251 text_ellip.set_property('ellipsize', pango.ELLIPSIZE_END)
252 except:
253 pass
254 column = gtk.TreeViewColumn(name, text_ellip, text = model_column)
255 column.set_expand(True)
256 elif model_column == InterfaceBrowser.VERSION:
257 column = gtk.TreeViewColumn(name, coloured_text, text = model_column,
258 background = InterfaceBrowser.BACKGROUND)
259 else:
260 column = gtk.TreeViewColumn(name, text, text = model_column)
261 tree_view.append_column(column)
262 column_objects.append(column)
264 tree_view.set_enable_search(True)
266 selection = tree_view.get_selection()
268 def button_press(tree_view, bev):
269 pos = tree_view.get_path_at_pos(int(bev.x), int(bev.y))
270 if not pos:
271 return False
272 path, col, x, y = pos
274 if (bev.button == 3 or (bev.button < 4 and col is menu_column)) \
275 and bev.type == gtk.gdk.BUTTON_PRESS:
276 selection.select_path(path)
277 iface = self.model[path][InterfaceBrowser.INTERFACE]
278 self.show_popup_menu(iface, bev)
279 return True
280 if bev.button != 1 or bev.type != gtk.gdk._2BUTTON_PRESS:
281 return False
282 properties.edit(driver, self.model[path][InterfaceBrowser.INTERFACE], self.compile, show_versions = True)
283 tree_view.connect('button-press-event', button_press)
285 tree_view.connect('destroy', lambda s: driver.watchers.remove(self.build_tree))
286 driver.watchers.append(self.build_tree)
288 def set_root(self, root):
289 assert isinstance(root, model.Interface)
290 self.root = root
292 def set_update_icons(self, update_icons):
293 if update_icons:
294 # Clear icons cache to make sure they're really updated
295 self.cached_icon = {}
296 self.update_icons = update_icons
298 def get_icon(self, iface):
299 """Get an icon for this interface. If the icon is in the cache, use that.
300 If not, start a download. If we already started a download (successful or
301 not) do nothing. Returns None if no icon is currently available."""
302 try:
303 # Try the in-memory cache
304 return self.cached_icon[iface.uri]
305 except KeyError:
306 # Try the on-disk cache
307 iconpath = self.config.iface_cache.get_icon_path(iface)
309 if iconpath:
310 icon = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
311 # (if icon is None, cache the fact that we can't load it)
312 self.cached_icon[iface.uri] = icon
313 else:
314 icon = None
316 # Download a new icon if we don't have one, or if the
317 # user did a 'Refresh'
318 if iconpath is None or self.update_icons:
319 if self.config.network_use == model.network_offline:
320 fetcher = None
321 else:
322 fetcher = self.config.fetcher.download_icon(iface)
323 if fetcher:
324 if iface.uri not in self.cached_icon:
325 self.cached_icon[iface.uri] = None # Only try once
327 @tasks.async
328 def update_display():
329 yield fetcher
330 try:
331 tasks.check(fetcher)
332 # Try to insert new icon into the cache
333 # If it fails, we'll be left with None in the cached_icon so
334 # we don't try again.
335 iconpath = self.config.iface_cache.get_icon_path(iface)
336 if iconpath:
337 self.cached_icon[iface.uri] = load_icon(iconpath, ICON_SIZE, ICON_SIZE)
338 self.build_tree()
339 else:
340 warn("Failed to download icon for '%s'", iface)
341 except Exception as ex:
342 import traceback
343 traceback.print_exc()
344 self.config.handler.report_error(ex)
345 update_display()
346 # elif fetcher is None: don't store anything in cached_icon
348 # Note: if no icon is available for downloading,
349 # more attempts are made later.
350 # It can happen that no icon is yet available because
351 # the interface was not downloaded yet, in which case
352 # it's desireable to try again once the interface is available
353 return icon
355 return None
357 def build_tree(self):
358 iface_cache = self.config.iface_cache
360 if self.original_implementation is None:
361 self.set_original_implementations()
363 done = {} # Detect cycles
365 sels = self.driver.solver.selections
367 self.model.clear()
368 def add_node(parent, iface, commands, essential):
369 if iface in done:
370 return
371 done[iface] = True
373 main_feed = iface_cache.get_feed(iface.uri)
374 if main_feed:
375 name = main_feed.get_name()
376 summary = main_feed.summary
377 else:
378 name = iface.get_name()
379 summary = None
381 iter = self.model.append(parent)
382 self.model[iter][InterfaceBrowser.INTERFACE] = iface
383 self.model[iter][InterfaceBrowser.INTERFACE_NAME] = name
384 self.model[iter][InterfaceBrowser.SUMMARY] = summary
385 self.model[iter][InterfaceBrowser.ICON] = self.get_icon(iface) or self.default_icon
386 self.model[iter][InterfaceBrowser.PROBLEM] = False
388 sel = sels.selections.get(iface.uri, None)
389 if sel:
390 impl = sel.impl
391 old_impl = self.original_implementation.get(iface, None)
392 version_str = impl.get_version()
393 if old_impl is not None and old_impl.id != impl.id:
394 version_str += _(' (was %s)') % old_impl.get_version()
395 self.model[iter][InterfaceBrowser.VERSION] = version_str
397 self.model[iter][InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.config, impl)
399 deps = sel.dependencies
400 for c in commands:
401 deps += sel.get_command(c).requires
402 for child in deps:
403 if isinstance(child, model.InterfaceDependency):
404 add_node(iter,
405 iface_cache.get_interface(child.interface),
406 child.get_required_commands(),
407 child.importance == model.Dependency.Essential)
408 else:
409 child_iter = self.model.append(parent)
410 self.model[child_iter][InterfaceBrowser.INTERFACE_NAME] = '?'
411 self.model[child_iter][InterfaceBrowser.SUMMARY] = \
412 _('Unknown dependency type : %s') % child
413 self.model[child_iter][InterfaceBrowser.ICON] = self.default_icon
414 else:
415 self.model[iter][InterfaceBrowser.PROBLEM] = essential
416 self.model[iter][InterfaceBrowser.VERSION] = _('(problem)') if essential else _('(none)')
417 if sels.command:
418 add_node(None, self.root, [sels.command], essential = True)
419 else:
420 add_node(None, self.root, [], essential = True)
421 self.tree_view.expand_all()
423 def show_popup_menu(self, iface, bev):
424 import bugs
426 have_source = properties.have_source_for(self.config, iface)
428 global menu # Fix GC problem in PyGObject
429 menu = gtk.Menu()
430 for label, cb in [(_('Show Feeds'), lambda: properties.edit(self.driver, iface, self.compile)),
431 (_('Show Versions'), lambda: properties.edit(self.driver, iface, self.compile, show_versions = True)),
432 (_('Report a Bug...'), lambda: bugs.report_bug(self.driver, iface))]:
433 item = gtk.MenuItem()
434 item.set_label(label)
435 if cb:
436 item.connect('activate', lambda item, cb=cb: cb())
437 else:
438 item.set_sensitive(False)
439 item.show()
440 menu.append(item)
442 item = gtk.MenuItem()
443 item.set_label(_('Compile'))
444 item.show()
445 menu.append(item)
446 if have_source:
447 compile_menu = gtk.Menu()
448 item.set_submenu(compile_menu)
450 item = gtk.MenuItem()
451 item.set_label(_('Automatic'))
452 item.connect('activate', lambda item: self.compile(iface, autocompile = True))
453 item.show()
454 compile_menu.append(item)
456 item = gtk.MenuItem()
457 item.set_label(_('Manual...'))
458 item.connect('activate', lambda item: self.compile(iface, autocompile = False))
459 item.show()
460 compile_menu.append(item)
461 else:
462 item.set_sensitive(False)
464 if gtk.pygtk_version >= (2, 90):
465 menu.popup(None, None, None, None, bev.button, bev.time)
466 else:
467 menu.popup(None, None, None, bev.button, bev.time)
469 def compile(self, interface, autocompile = True):
470 import compile
471 def on_success():
472 # A new local feed may have been registered, so reload it from the disk cache
473 info(_("0compile command completed successfully. Reloading interface details."))
474 reader.update_from_cache(interface)
475 for feed in interface.extra_feeds:
476 self.config.iface_cache.get_feed(feed.uri, force = True)
477 import main
478 main.recalculate()
479 compile.compile(on_success, interface.uri, autocompile = autocompile)
481 def set_original_implementations(self):
482 assert self.original_implementation is None
483 self.original_implementation = self.driver.solver.selections.copy()
485 def update_download_status(self, only_update_visible = False):
486 """Called at regular intervals while there are downloads in progress,
487 and once at the end. Also called when things are added to the store.
488 Update the TreeView with the interfaces."""
490 # A download may be for a feed, an interface or an implementation.
491 # Create the reverse mapping (item -> download)
492 hints = {}
493 for dl in self.config.handler.monitored_downloads:
494 if dl.hint:
495 if dl.hint not in hints:
496 hints[dl.hint] = []
497 hints[dl.hint].append(dl)
499 selections = self.driver.solver.selections
501 # Only update currently visible rows
502 if only_update_visible and self.tree_view.get_visible_range() != None:
503 firstVisiblePath, lastVisiblePath = self.tree_view.get_visible_range()
504 firstVisibleIter = self.model.get_iter(firstVisiblePath)
505 else:
506 # (or should we just wait until the TreeView has settled enough to tell
507 # us what is visible?)
508 firstVisibleIter = self.model.get_iter_root()
509 lastVisiblePath = None
511 solver = self.driver.solver
512 requirements = self.driver.requirements
513 iface_cache = self.config.iface_cache
515 for it in walk(self.model, firstVisibleIter):
516 row = self.model[it]
517 iface = row[InterfaceBrowser.INTERFACE]
519 # Is this interface the download's hint?
520 downloads = hints.get(iface, []) # The interface itself
521 downloads += hints.get(iface.uri, []) # The main feed
523 arch = solver.get_arch_for(requirements, iface)
524 for feed in iface_cache.usable_feeds(iface, arch):
525 downloads += hints.get(feed.uri, []) # Other feeds
526 impl = selections.get(iface, None)
527 if impl:
528 downloads += hints.get(impl, []) # The chosen implementation
530 if downloads:
531 so_far = 0
532 expected = None
533 for dl in downloads:
534 if dl.expected_size:
535 expected = (expected or 0) + dl.expected_size
536 so_far += dl.get_bytes_downloaded_so_far()
537 if expected:
538 summary = ngettext("(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%])",
539 "(downloading %(downloaded)s/%(expected)s [%(percentage).2f%%] in %(number)d downloads)",
540 downloads)
541 values_dict = {'downloaded': pretty_size(so_far), 'expected': pretty_size(expected), 'percentage': 100 * so_far / float(expected), 'number': len(downloads)}
542 else:
543 summary = ngettext("(downloading %(downloaded)s/unknown)",
544 "(downloading %(downloaded)s/unknown in %(number)d downloads)",
545 downloads)
546 values_dict = {'downloaded': pretty_size(so_far), 'number': len(downloads)}
547 row[InterfaceBrowser.SUMMARY] = summary % values_dict
548 else:
549 row[InterfaceBrowser.DOWNLOAD_SIZE] = utils.get_fetch_info(self.config, impl)
550 row[InterfaceBrowser.SUMMARY] = iface.summary
552 if self.model.get_path(it) == lastVisiblePath:
553 break
555 def highlight_problems(self):
556 """Called when the solve finishes. Highlight any missing implementations."""
557 for it in walk(self.model, self.model.get_iter_root()):
558 row = self.model[it]
559 iface = row[InterfaceBrowser.INTERFACE]
560 sel = self.driver.solver.selections.selections.get(iface.uri, None)
562 if sel is None and row[InterfaceBrowser.PROBLEM]:
563 row[InterfaceBrowser.BACKGROUND] = '#f88'