From 7837ba976c36716210bab83db4f699435f47d383 Mon Sep 17 00:00:00 2001 From: gfxmonk Date: Sat, 19 Mar 2011 23:17:33 +1100 Subject: [PATCH] Cache explorer UI changes UI Changes: - Show the name of each package in bold (as well as the URI) - Tooltip is now just the description, and is implemented by setting the tooltip column (I'm not sure if the old query_tooltip method provided anything extra, but if it does I couldn't detect it) - Sort options - URI, Name and Size - Filter options: feeds with/without impls, local / remote feeds - 0 size is represented as "-" - Leave error and unknown interfaces collapsed on load - Remote Interfaces (but not local ones) can be deleted - this will delete all cached implementations (not touching local impls) and the interface itself. - Interactive search works (always searches on name) - Right-click context menu for interfaces with actions: - Launch in GUI - copy interface URI - delete Code stuff: - Column indexes are replaced with column objects (that know their type, index, name, etc) - Sections are replaced with section objects - get_children(model, iter) helper method - model values are set with a call to extract_columns(), making use of named paramaters (so that order doesn't matter, and all non-provided values can be sensibly defaulted) - added SORT_OPTIONS and FILTER_OPTIONS, which do what you might expect. - tooltips are implemented by setting the tooltip column in the .ui file. It doesn't appear that `query_tooltip` did anything more fancy. - CacheExplorer.model has been replaced with raw_model(for setting data) and view_model.get_model() (for getting / updating data). There is a convenience getter on CacheExplorer (.model) to get view_model.get_model(). This is required for filtered views to work. - ValidInterface now has two concrete sub-classes, LocalInterface and RemoteInterface. The split is based on whether the URI exists as a path. This will treat now-missing local feeds as a remote feed, but that seems reasonable (it's no longer present, so it should be deletable). - added a SAFE_MODE flag to just print out file deletions, instead of doing so (for development) Added names to the cache view; tweaked UI a bunch --- zeroinstall/gtkui/cache.py | 432 ++++++++++++++++++++++++++++++++++----------- zeroinstall/gtkui/cache.ui | 94 +++++++++- 2 files changed, 422 insertions(+), 104 deletions(-) diff --git a/zeroinstall/gtkui/cache.py b/zeroinstall/gtkui/cache.py index cbf9173..4ad17da 100644 --- a/zeroinstall/gtkui/cache.py +++ b/zeroinstall/gtkui/cache.py @@ -16,14 +16,114 @@ __all__ = ['CacheExplorer'] ROX_IFACE = 'http://rox.sourceforge.net/2005/interfaces/ROX-Filer' -# Model columns -ITEM = 0 -SELF_SIZE = 1 -PRETTY_SIZE = 2 -TOOLTIP = 3 -ITEM_OBJECT = 4 - -def popup_menu(bev, obj): +# Tree view columns +class Column(object): + columns = [] + def __init__(self, name, column_type, resizable=False, props={}, hide=False, markup=False): + self.idx = len(self.columns) + self.columns.append(self) + self.name = name + self.column_type = column_type + self.props = props + self.resizable = resizable + self.hide = hide + self.markup = markup + + @classmethod + def column_types(cls): + return [col.column_type for col in cls.columns] + + @classmethod + def add_all(cls, tree_view): + [col.add(tree_view) for col in cls.columns] + + def get_cell(self): + cell = gtk.CellRendererText() + self.set_props(cell, self.props) + return cell + + def set_props(self, obj, props): + for k,v in props.items(): + obj.set_property(k, v) + + def get_column(self): + if self.markup: + kwargs = {'markup': self.idx} + else: + kwargs = {'text': self.idx} + column = gtk.TreeViewColumn(self.name, self.get_cell(), **kwargs) + if 'xalign' in self.props: + self.set_props(column, {'alignment': self.props['xalign']}) + return column + + def add(self, tree_view): + if self.hide: + return + column = self.get_column() + if self.resizable: column.set_resizable(True) + tree_view.append_column(column) + +NAME = Column(_('Name'), str, hide=True) +URI = Column(_('URI'), str, hide=True) +TOOLTIP = Column(_('Description'), str, hide=True) +ITEM_VIEW = Column(_('Item'), str, props={'ypad': 6, 'yalign': 0}, resizable=True, markup=True) +SELF_SIZE = Column(_('Self Size'), int, hide=True) +TOTAL_SIZE = Column(_('Total Size'), int, hide=True) +PRETTY_SIZE = Column(_('Size'), str, props={'xalign':1.0}) +ITEM_OBJECT = Column(_('Object'), object, hide=True) + +ACTION_REMOVE = object() # just make a unique value + +class Section(object): + may_delete = False + def __init__(self, name, tooltip): + self.name = name + self.tooltip = tooltip + + def append_to(self, model): + return model.append(None, extract_columns( + name=self.name, + tooltip=self.tooltip, + object=self, + )) + +SECTION_INTERFACES = Section( + _("Interfaces"), + _("Interfaces in the cache")) +SECTION_UNOWNED_IMPLEMENTATIONS = Section( + _("Unowned implementations and temporary files"), + _("These probably aren't needed any longer. You can delete them.")) +SECTION_INVALID_INTERFACES = Section( + _("Invalid interfaces (unreadable)"), + _("These interfaces exist in the cache but cannot be read. You should probably delete them.")) + +import cgi +def extract_columns(**d): + vals = list(map(lambda x:None, Column.columns)) + def setcol(column, val): + vals[column.idx] = val + + name = d.get('name', None) + desc = d.get('desc', None) + uri = d.get('uri', None) + + setcol(NAME, name) + setcol(URI, uri) + if name and uri: + setcol(ITEM_VIEW, '%s\n' + '%s' % tuple(map(cgi.escape, (name, uri)))) + else: + setcol(ITEM_VIEW, name or desc) + + size = d.get('size', 0) + setcol(SELF_SIZE, size) + setcol(TOTAL_SIZE, 0) # must be set to prevent type error + setcol(TOOLTIP, d.get('tooltip', None)) + setcol(ITEM_OBJECT, d.get('object', None)) + return vals + + +def popup_menu(bev, obj, model, path, cache_explorer): menu = gtk.Menu() for i in obj.menu_items: if i is None: @@ -31,11 +131,30 @@ def popup_menu(bev, obj): else: name, cb = i item = gtk.MenuItem(name) - item.connect('activate', lambda item, cb=cb: cb(obj)) + def _cb(item, cb=cb): + action_required = cb(obj, cache_explorer) + if action_required is ACTION_REMOVE: + model.remove(model.get_iter(path)) + item.connect('activate', _cb) item.show() menu.append(item) menu.popup(None, None, None, bev.button, bev.time) +def warn(message, parent=None): + "Present a blocking warning message with OK/Cancel buttons, and return True if OK was pressed" + dialog = gtk.MessageDialog(parent=parent, buttons=gtk.BUTTONS_OK_CANCEL, type=gtk.MESSAGE_WARNING) + dialog.set_property('text', message) + response = [] + def _response(dialog, resp): + if resp == gtk.RESPONSE_OK: + response.append(True) + dialog.connect('response', _response) + dialog.run() + dialog.destroy() + return bool(response) + if response: + return True + def size_if_exists(path): "Get the size for a file, or 0 if it doesn't exist." if path and os.path.isfile(path): @@ -71,8 +190,17 @@ def get_selected_paths(tree_view): selection.selected_foreach(add) return paths +def all_children(model, iter): + "make a python generator out of the children of `iter`" + iter = model.iter_children(iter) + while iter: + yield iter + iter = model.iter_next(iter) + # Responses DELETE = 0 +SAFE_MODE = False # really delete things +#SAFE_MODE = True # print deletes, instead of performing them class CachedInterface(object): def __init__(self, uri, size): @@ -84,14 +212,18 @@ class CachedInterface(object): cached_iface = basedir.load_first_cache(namespaces.config_site, 'interfaces', model.escape(self.uri)) if cached_iface: - #print "Delete", cached_iface - os.unlink(cached_iface) + if SAFE_MODE: + print "Delete", cached_iface + else: + os.unlink(cached_iface) user_overrides = basedir.load_first_config(namespaces.config_site, namespaces.config_prog, 'user_overrides', model.escape(self.uri)) if user_overrides: - #print "Delete", user_overrides - os.unlink(user_overrides) + if SAFE_MODE: + print "Delete", cached_iface + else: + os.unlink(user_overrides) def __cmp__(self, other): return self.uri.__cmp__(other.uri) @@ -102,20 +234,56 @@ class ValidInterface(CachedInterface): self.iface = iface self.in_cache = [] + def delete_children(self): + deletable = self.deletable_children() + undeletable = list(filter(lambda child: not child.may_delete, self.in_cache)) + # the only undeletable items we expect to encounter are LocalImplementations + unexpected_undeletable = list(filter(lambda child: not isinstance(child, LocalImplementation), undeletable)) + assert not unexpected_undeletable, "unexpected undeletable items!: %r" % (unexpected_undeletable,) + [child.delete() for child in deletable] + + def delete(self): + self.delete_children() + super(ValidInterface, self).delete() + def append_to(self, model, iter): - iter2 = model.append(iter, - [self.uri, self.size, None, summary(self.iface), self]) + iter2 = model.append(iter, extract_columns( + name=self.iface.get_name(), + uri=self.uri, + tooltip=self.iface.summary, + object=self)) for cached_impl in self.in_cache: cached_impl.append_to(model, iter2) - - def get_may_delete(self): - for c in self.in_cache: - if not isinstance(c, LocalImplementation): - return False # Still some impls cached - return True - may_delete = property(get_may_delete) + def launch(self, explorer): + os.spawnlp(os.P_NOWAIT, '0launch', '0launch', '--gui', self.uri) + + def copy_uri(self, explorer): + clipboard = gtk.clipboard_get() + clipboard.set_text(self.uri) + + def deletable_children(self): + return list(filter(lambda child: child.may_delete, self.in_cache)) + def prompt_delete(self, cache_explorer): + description = "\"%s\"" % (self.iface.get_name(),) + num_children = len(self.deletable_children()) + if self.in_cache: + description += _(" (and %s %s)") % (num_children, _("implementation") if num_children == 1 else _("implementations")) + if warn(_("Really delete %s?") % (description,), parent=cache_explorer.window): + self.delete() + return ACTION_REMOVE + + menu_items = [(_('Launch with GUI'), launch), + (_('Copy URI to clipboard'), copy_uri), + (_('Delete'), prompt_delete)] + +class RemoteInterface(ValidInterface): + may_delete = True + +class LocalInterface(ValidInterface): + may_delete = False + class InvalidInterface(CachedInterface): may_delete = True @@ -133,7 +301,11 @@ class LocalImplementation: self.impl = impl def append_to(self, model, iter): - model.append(iter, [self.impl.local_path, 0, None, _('This is a local version, not held in the cache.'), self]) + model.append(iter, extract_columns( + name=self.impl.local_path, + tooltip=_('This is a local version, not held in the cache.'), + object=self)) + class CachedImplementation: may_delete = True @@ -144,13 +316,15 @@ class CachedImplementation: self.digest = digest def delete(self): - #print "Delete", self.impl_path - support.ro_rmtree(self.impl_path) + if SAFE_MODE: + print "Delete", self.impl_path + else: + support.ro_rmtree(self.impl_path) - def open_rox(self): + def open_rox(self, explorer): os.spawnlp(os.P_WAIT, '0launch', '0launch', ROX_IFACE, '-d', self.impl_path) - def verify(self): + def verify(self, explorer): try: manifest.verify(self.impl_path) except BadDigest, ex: @@ -176,13 +350,23 @@ class CachedImplementation: _('Contents match digest; nothing has been changed.')) box.run() box.destroy() + + def prompt_delete(self, explorer): + if warn(_("Really delete implementation?"), parent=explorer.window): + self.delete() + return ACTION_REMOVE menu_items = [(_('Open in ROX-Filer'), open_rox), - (_('Verify integrity'), verify)] + (_('Verify integrity'), verify), + (_('Delete'), prompt_delete)] class UnusedImplementation(CachedImplementation): def append_to(self, model, iter): - model.append(iter, [self.digest, self.size, None, self.impl_path, self]) + model.append(iter, extract_columns( + name=self.digest, + size=self.size, + tooltip=self.impl_path, + object=self)) class KnownImplementation(CachedImplementation): def __init__(self, cached_iface, cache_dir, impl, impl_size, digest): @@ -192,16 +376,19 @@ class KnownImplementation(CachedImplementation): self.size = impl_size def delete(self): - CachedImplementation.delete(self) - self.cached_iface.in_cache.remove(self) + if SAFE_MODE: + print "Delete", self.impl + else: + CachedImplementation.delete(self) + self.cached_iface.in_cache.remove(self) def append_to(self, model, iter): - model.append(iter, - [_('Version %(implementation_version)s : %(implementation_id)s') % {'implementation_version': self.impl.get_version(), 'implementation_id': self.impl.id}, - self.size, None, - None, - self]) - + model.append(iter, extract_columns( + name=_('Version %(implementation_version)s : %(implementation_id)s') % {'implementation_version': self.impl.get_version(), 'implementation_id': self.impl.id}, + size=self.size, + tooltip=self.impl_path, + object=self)) + def __cmp__(self, other): if hasattr(other, 'impl'): return self.impl.__cmp__(other.impl) @@ -209,6 +396,7 @@ class KnownImplementation(CachedImplementation): class CacheExplorer: """A graphical interface for viewing the cache and deleting old items.""" + def __init__(self, iface_cache): widgets = gtkutils.Template(os.path.join(os.path.dirname(__file__), 'cache.ui'), 'cache') self.window = window = widgets.get_widget('cache') @@ -216,18 +404,45 @@ class CacheExplorer: self.iface_cache = iface_cache # Model - self.model = gtk.TreeStore(str, int, str, str, object) + self.raw_model = gtk.TreeStore(*Column.column_types()) + self.view_model = self.raw_model.filter_new() + self.model.set_sort_column_id(URI.idx, gtk.SORT_ASCENDING) self.tree_view = widgets.get_widget('treeview') - self.tree_view.set_model(self.model) - - column = gtk.TreeViewColumn(_('Item'), gtk.CellRendererText(), text = ITEM) - column.set_resizable(True) - self.tree_view.append_column(column) - - cell = gtk.CellRendererText() - cell.set_property('xalign', 1.0) - column = gtk.TreeViewColumn(_('Size'), cell, text = PRETTY_SIZE) - self.tree_view.append_column(column) + self.tree_view.set_model(self.view_model) + Column.add_all(self.tree_view) + + # Sort / Filter options: + + def init_combo(combobox, items, on_select): + liststore = gtk.ListStore(str) + combobox.set_model(liststore) + cell = gtk.CellRendererText() + combobox.pack_start(cell, True) + combobox.add_attribute(cell, 'text', 0) + for item in items: + combobox.append_text(item[0]) + combobox.set_active(0) + def _on_select(*a): + selected_item = combobox.get_active() + on_select(selected_item) + combobox.connect('changed', lambda *a: on_select(items[combobox.get_active()])) + + def set_sort_order(sort_order): + print "SORT: %r" % (sort_order,) + name, column, order = sort_order + self.model.set_sort_column_id(column.idx, order) + self.sort_combo = widgets.get_widget('sort_combo') + init_combo(self.sort_combo, SORT_OPTIONS, set_sort_order) + + def set_filter(f): + print "FILTER: %r" % (f,) + description, filter_func = f + self.view_model = self.model.filter_new() + self.view_model.set_visible_func(filter_func) + self.tree_view.set_model(self.view_model) + self.set_initial_expansion() + self.filter_combo = widgets.get_widget('filter_combo') + init_combo(self.filter_combo, FILTER_OPTIONS, set_filter) def button_press(tree_view, bev): if bev.button != 3: @@ -236,27 +451,11 @@ class CacheExplorer: if not pos: return False path, col, x, y = pos - obj = self.model[path][ITEM_OBJECT] + obj = self.model[path][ITEM_OBJECT.idx] if obj and hasattr(obj, 'menu_items'): - popup_menu(bev, obj) + popup_menu(bev, obj, model=self.model, path=path, cache_explorer=self) self.tree_view.connect('button-press-event', button_press) - # Tree tooltips - self.tree_view.set_property('has-tooltip', True) - def query_tooltip(widget, x, y, keyboard_mode, tooltip): - x, y = self.tree_view.convert_widget_to_bin_window_coords(x, y) - pos = self.tree_view.get_path_at_pos(x, y) - if pos: - path = pos[0] - row = self.model[path] - tip = row[TOOLTIP] - if tip: - self.tree_view.set_tooltip_cell(tooltip, pos[0], None, None) - tooltip.set_text(tip) - return True - return False - self.tree_view.connect('query-tooltip', query_tooltip) - # Responses window.set_default_response(gtk.RESPONSE_CLOSE) @@ -264,7 +463,7 @@ class CacheExplorer: def selection_changed(selection): any_selected = False for x in get_selected_paths(self.tree_view): - obj = self.model[x][ITEM_OBJECT] + obj = self.model[x][ITEM_OBJECT.idx] if obj is None or not obj.may_delete: window.set_response_sensitive(DELETE, False) return @@ -283,6 +482,10 @@ class CacheExplorer: self._delete() window.connect('response', response) + @property + def model(self): + return self.view_model.get_model() + def _delete(self): errors = [] @@ -290,7 +493,7 @@ class CacheExplorer: paths = get_selected_paths(self.tree_view) paths.reverse() for path in paths: - item = model[path][ITEM_OBJECT] + item = model[path][ITEM_OBJECT.idx] assert item.delete try: item.delete() @@ -308,12 +511,18 @@ class CacheExplorer: self.window.show() self.window.window.set_cursor(gtkutils.get_busy_pointer()) gtk.gdk.flush() + self._populate_model() + self.set_initial_expansion() + + def set_initial_expansion(self): + model = self.model try: - self._populate_model() - i = self.model.get_iter_root() + i = model.get_iter_root() while i: - self.tree_view.expand_row(self.model.get_path(i), False) - i = self.model.iter_next(i) + # expand only "Interfaces" + if model[i][ITEM_OBJECT.idx] is SECTION_INTERFACES: + self.tree_view.expand_row(model.get_path(i), False) + i = model.iter_next(i) finally: self.window.window.set_cursor(None) @@ -341,7 +550,9 @@ class CacheExplorer: try: if os.path.isabs(uri): cached_iface = uri + interface_type = LocalInterface else: + interface_type = RemoteInterface cached_iface = basedir.load_first_cache(namespaces.config_site, 'interfaces', model.escape(uri)) user_overrides = basedir.load_first_config(namespaces.config_site, @@ -353,7 +564,7 @@ class CacheExplorer: except Exception, ex: error_interfaces.append((uri, str(ex), iface_size)) else: - cached_iface = ValidInterface(iface, iface_size) + cached_iface = interface_type(iface, iface_size) for impl in iface.implementations.values(): if impl.local_path: cached_iface.in_cache.append(LocalImplementation(impl)) @@ -368,14 +579,10 @@ class CacheExplorer: ok_interfaces.append(cached_iface) if error_interfaces: - iter = self.model.append(None, [_("Invalid interfaces (unreadable)"), - 0, None, - _("These interfaces exist in the cache but cannot be " - "read. You should probably delete them."), - None]) + iter = SECTION_INVALID_INTERFACES.append_to(self.raw_model) for uri, ex, size in error_interfaces: item = InvalidInterface(uri, ex, size) - item.append_to(self.model, iter) + item.append_to(self.raw_model, iter) unowned_sizes = [] local_dir = os.path.join(basedir.xdg_cache_home, '0install.net', 'implementations') @@ -384,41 +591,66 @@ class CacheExplorer: impl = UnusedImplementation(local_dir, id) unowned_sizes.append((impl.size, impl)) if unowned_sizes: - iter = self.model.append(None, [_("Unowned implementations and temporary files"), - 0, None, - _("These probably aren't needed any longer. You can " - "delete them."), None]) - unowned_sizes.sort() - unowned_sizes.reverse() + iter = SECTION_UNOWNED_IMPLEMENTATIONS.append_to(self.raw_model) for size, item in unowned_sizes: - item.append_to(self.model, iter) + item.append_to(self.raw_model, iter) if ok_interfaces: - iter = self.model.append(None, - [_("Interfaces"), - 0, None, - _("Interfaces in the cache"), - None]) + iter = SECTION_INTERFACES.append_to(self.raw_model) for item in ok_interfaces: - item.append_to(self.model, iter) + item.append_to(self.raw_model, iter) self._update_sizes() def _update_sizes(self): - """Set PRETTY_SIZE to the total size, including all children.""" - m = self.model + """Set TOTAL_SIZE and PRETTY_SIZE to the total size, including all children.""" + m = self.raw_model def update(itr): - total = m[itr][SELF_SIZE] - child = m.iter_children(itr) - while child: - total += update(child) - child = m.iter_next(child) - m[itr][PRETTY_SIZE] = support.pretty_size(total) + total = m[itr][SELF_SIZE.idx] + total += sum(map(update, all_children(m, itr))) + m[itr][PRETTY_SIZE.idx] = support.pretty_size(total) if total else '-' + m[itr][TOTAL_SIZE.idx] = total return total itr = m.get_iter_root() while itr: update(itr) itr = m.iter_next(itr) + +SORT_OPTIONS = [ + ('URI', URI, gtk.SORT_ASCENDING), + ('Name', NAME, gtk.SORT_ASCENDING), + ('Size', TOTAL_SIZE, gtk.SORT_DESCENDING), +] + +def init_filters(): + def filter_only(filterable_types, filter_func): + def _filter(model, iter): + obj = model.get_value(iter, ITEM_OBJECT.idx) + if any((isinstance(obj, t) for t in filterable_types)): + result = filter_func(model, iter) + return result + return True + return _filter + + def not_(func): + return lambda *a: not func(*a) + + def is_local_feed(model, iter): + return isinstance(model[iter][ITEM_OBJECT.idx], LocalInterface) + + def has_implementations(model, iter): + return model.iter_has_child(iter) + + return [ + ('All', lambda *a: True), + ('Feeds with implementations', filter_only([ValidInterface], has_implementations)), + ('Feeds without implementations', filter_only([ValidInterface], not_(has_implementations))), + ('Local Feeds', filter_only([ValidInterface], is_local_feed)), + ('Remote Feeds', filter_only([ValidInterface], not_(is_local_feed))), + ] +FILTER_OPTIONS = init_filters() + + cache_help = help_box.HelpBox(_("Cache Explorer Help"), (_('Overview'), '\n' + _("""When you run a program using Zero Install, it downloads the program's 'interface' file, \ diff --git a/zeroinstall/gtkui/cache.ui b/zeroinstall/gtkui/cache.ui index af7a4ac..7d96b2e 100644 --- a/zeroinstall/gtkui/cache.ui +++ b/zeroinstall/gtkui/cache.ui @@ -1,15 +1,97 @@ - + Zero Install cache dialog - False True + + True + 20 + + + True + 2 + 5 + + + True + 1 + Sort by: + True + + + + + True + + + 1 + 2 + + + + + + False + False + 5 + 0 + + + + + True + 2 + 5 + + + True + 1 + Show: + True + + + + + True + + + 1 + 2 + GTK_EXPAND + + + + + False + False + 5 + 1 + + + + + False + False + 8 + 0 + + + + + True + + + False + 1 + + + True True @@ -18,12 +100,16 @@ True True + False + True True + 0 + 2 - 1 + 2 @@ -79,7 +165,7 @@ False end - 0 + 3 -- 2.11.4.GIT