From 01ef033a6387effb16ba72c580b0387068798f09 Mon Sep 17 00:00:00 2001 From: Julio Daniel Reyes Date: Wed, 26 Jun 2013 16:03:38 +0000 Subject: Added multi-select journal --- diff --git a/data/icons/select-all.svg b/data/icons/select-all.svg new file mode 100644 index 0000000..f1c29a9 --- /dev/null +++ b/data/icons/select-all.svg @@ -0,0 +1,80 @@ + +image/svg+xml diff --git a/data/icons/select-none.svg b/data/icons/select-none.svg new file mode 100644 index 0000000..0427384 --- /dev/null +++ b/data/icons/select-none.svg @@ -0,0 +1,72 @@ + +image/svg+xml diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index bb1c7f6..44a2029 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -17,7 +17,9 @@ import logging from gettext import gettext as _ +from gettext import ngettext import uuid +import gobject import gtk import dbus @@ -25,7 +27,8 @@ import statvfs import os from sugar.graphics.window import Window -from sugar.graphics.alert import ErrorAlert +from sugar.graphics.alert import Alert, ErrorAlert +from sugar.graphics.icon import Icon from sugar.bundle.bundle import ZipExtractException, RegistrationException from sugar import env @@ -34,6 +37,7 @@ from sugar import wm from jarabe.model import bundleregistry from jarabe.journal.journaltoolbox import MainToolbox, DetailToolbox +from jarabe.journal.journaltoolbox import EditToolbox from jarabe.journal.listview import ListView from jarabe.journal.detailview import DetailView from jarabe.journal.volumestoolbar import VolumesToolbar @@ -118,9 +122,12 @@ class JournalActivity(JournalWindow): self._secondary_view = None self._list_view = None self._detail_view = None + self._edit_toolbox = None self._main_toolbox = None self._detail_toolbox = None self._volumes_toolbar = None + self._editing_mode = False + self._editing_alert = None self._setup_main_view() self._setup_secondary_view() @@ -141,6 +148,7 @@ class JournalActivity(JournalWindow): self._dbus_service = JournalActivityDBusService(self) self.iconify() + self._iconified = True self._critical_space_alert = None self._check_available_space() @@ -172,6 +180,7 @@ class JournalActivity(JournalWindow): self._list_view.connect('detail-clicked', self.__detail_clicked_cb) self._list_view.connect('clear-clicked', self.__clear_clicked_cb) self._list_view.connect('volume-error', self.__volume_error_cb) + self._list_view.connect('select-toggled', self.__select_toggled_cb) self._main_view.pack_start(self._list_view) self._list_view.show() @@ -185,6 +194,14 @@ class JournalActivity(JournalWindow): search_toolbar.connect('query-changed', self._query_changed_cb) search_toolbar.set_mount_point('/') + self._edit_toolbox = EditToolbox() + edit_toolbar = self._edit_toolbox.edit_toolbar + edit_toolbar.connect('edit-none', self.__edit_none_activated_cb) + edit_toolbar.connect('edit-all', self.__edit_all_activated_cb) + edit_toolbar.connect('edit-erase', self.__edit_erase_activated_cb) + edit_toolbar.connect('edit-copy', self.__edit_copy_activated_cb) + + def _setup_secondary_view(self): self._secondary_view = gtk.VBox() @@ -197,6 +214,85 @@ class JournalActivity(JournalWindow): self._secondary_view.pack_end(self._detail_view) self._detail_view.show() + def __edit_none_activated_cb(self, toolbar): + list_model = self._list_view.get_model() + list_model.set_selection_none() + + def __edit_all_activated_cb(self, toolbar): + list_model = self._list_view.get_model() + list_model.set_selection_all() + + def _remove_editing_alert(self): + if self._editing_alert is not None: + self.remove_alert(self._editing_alert) + self._editing_alert = None + + def _add_editing_alert(self, title, message, operation, callback, data): + cancel_icon = Icon(icon_name='dialog-cancel') + ok_icon = Icon(icon_name='dialog-ok') + + alert = Alert() + alert.props.title = title + alert.props.msg = message + alert.add_button(gtk.RESPONSE_CANCEL, _('Cancel'), cancel_icon) + alert.add_button(gtk.RESPONSE_OK, operation, ok_icon) + alert.connect('response', callback, data) + alert.show() + + self._remove_editing_alert() + self._editing_alert = alert + self.add_alert(alert) + + def __edit_erase_activated_cb(self, toolbar): + list_model = self._list_view.get_model() + entries_set = list_model.get_selection() + entries_len = len(entries_set) + + message = ngettext('Do you want to erase %d entry?', + 'Do you want to erase %d entries?', + entries_len) % entries_len + + self._add_editing_alert(_('Confirm erase'), message, _('Erase'), + self.__edit_erase_confirm_cb, entries_set) + + def __edit_erase_confirm_cb(self, alert, response_id, entries_set): + self._remove_editing_alert() + if response_id == gtk.RESPONSE_OK: + gobject.idle_add(self._edit_erase_selection, entries_set) + + def _edit_erase_selection(self, entries_set): + mount_path = self._list_view.get_mountpoint() + model.delete_entries(entries_set, mount_path) + + def __edit_copy_activated_cb(self, toolbar, mount_info, mount_path): + list_model = self._list_view.get_model() + entries_set = list_model.get_selection() + entries_len = len(entries_set) + + message = ngettext('Do you want to copy %d entry to %s?', + 'Do you want to copy %d entries to %s?', + entries_len) % (entries_len, mount_info) + + self._add_editing_alert(_('Confirm copy'), message, _('Copy'), + self.__edit_copy_confirm_cb, (entries_set, mount_path)) + + def __edit_copy_confirm_cb(self, alert, response_id, data): + entries_set, mount_path = data + self._remove_editing_alert() + if response_id == gtk.RESPONSE_OK: + gobject.idle_add(self._edit_copy_selection, + entries_set, mount_path) + + def _edit_copy_selection(self, entries_set, mount_path): + status, message = model.copy_entries(entries_set, mount_path) + + if status is False: + alert = ErrorAlert(title=_('Copying error'), msg=message) + alert.connect('response', self.__alert_response_cb) + alert.show() + self.add_alert(alert) + + def _key_press_event_cb(self, widget, event): keyname = gtk.gdk.keyval_name(event.keyval) if keyname == 'Escape': @@ -208,6 +304,15 @@ class JournalActivity(JournalWindow): def __clear_clicked_cb(self, list_view): self._main_toolbox.search_toolbar.clear_query() + def __select_toggled_cb(self, list_view, mode): + logging.debug('Selection mode is %s', str(mode)) + self._editing_mode = mode + + # HACK: Don't exit detail view + if self.toolbar_box != self._detail_toolbox: + self.show_main_view() + + def __go_back_clicked_cb(self, detail_view): self.show_main_view() @@ -216,15 +321,28 @@ class JournalActivity(JournalWindow): self.show_main_view() def show_main_view(self): - if self.toolbar_box != self._main_toolbox: - self.set_toolbar_box(self._main_toolbox) - self._main_toolbox.show() + self._remove_editing_alert() + + if self._editing_mode: + #HACK: Hide current mount point copy-to option + mount_point = self._list_view.get_mountpoint() + edit_toolbar = self._edit_toolbox.edit_toolbar + edit_toolbar.arrange_copy_options(mount_point) + + toolbox = self._edit_toolbox + else: + toolbox = self._main_toolbox + if self.toolbar_box != toolbox: + self.set_toolbar_box(toolbox) + toolbox.show() if self.canvas != self._main_view: self.set_canvas(self._main_view) self._main_view.show() def _show_secondary_view(self, object_id): + self._remove_editing_alert() + metadata = model.get(object_id) try: self._detail_toolbox.entry_toolbar.set_metadata(metadata) @@ -322,11 +440,15 @@ class JournalActivity(JournalWindow): logging.debug('window_state_event_cb %r', self) if event.changed_mask & gtk.gdk.WINDOW_STATE_ICONIFIED: state = event.new_window_state - visible = not state & gtk.gdk.WINDOW_STATE_ICONIFIED + self._iconified = state & gtk.gdk.WINDOW_STATE_ICONIFIED + visible = not self._iconified self._list_view.set_is_visible(visible) + def __visibility_notify_event_cb(self, window, event): logging.debug('visibility_notify_event_cb %r', self) + if self._iconified: + return visible = event.state != gtk.gdk.VISIBILITY_FULLY_OBSCURED self._list_view.set_is_visible(visible) diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py index 2aa4153..4b5786b 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -526,6 +526,182 @@ class EntryToolbar(gtk.Toolbar): palette.menu.append(menu_item) menu_item.show() +class EditToolbox(Toolbox): + def __init__(self): + Toolbox.__init__(self) + + self.edit_toolbar = EditToolbar() + self.add_toolbar('', self.edit_toolbar) + self.edit_toolbar.show() + + +class EditToolbar(gtk.Toolbar): + + __gsignals__ = { + 'edit-none': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([])), + 'edit-all': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([])), + 'edit-erase': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([])), + 'edit-copy': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([str, str])) + } + + def __init__(self): + gtk.Toolbar.__init__(self) + + none_button = ToolButton('select-none') + none_button.set_tooltip(_('Select none')) + none_button.connect('clicked', self.__none_clicked_cb) + none_button.show() + self.add(none_button) + + all_button = ToolButton('select-all') + all_button.set_tooltip(_('Select all')) + all_button.connect('clicked', self.__all_clicked_cb) + all_button.show() + self.add(all_button) + + separator = gtk.SeparatorToolItem() + separator.show() + self.add(separator) + + erase_button = ToolButton('edit-delete') + erase_button.set_tooltip(_('Erase')) + erase_button.connect('clicked', self.__erase_clicked_cb) + erase_button.show() + self.add(erase_button) + + self._copy_button = EditCopyButton() + self._copy_button.connect('button-edit-copy', self.__copy_clicked_cb) + self._copy_button.show() + self.add(self._copy_button) + + def __none_clicked_cb(self, button): + logging.debug('Edit toolbar emitting none signal') + self.emit('edit-none') + + def __all_clicked_cb(self, button): + logging.debug('Edit toolbar emitting all signal') + self.emit('edit-all') + + def __erase_clicked_cb(self, button): + logging.debug('Edit toolbar emitting erase signal') + self.emit('edit-erase') + + def __copy_clicked_cb(self, button, mount_info, mount_path): + logging.debug('Edit toolbar emitting copy signal') + self.emit('edit-copy', mount_info, mount_path) + + def arrange_copy_options(self, mount_path): + self._copy_button.arrange_options(mount_path) + + +class EditCopyButton(ToolButton): + __gtype_name__ = 'JournalEditCopyButton' + + __gsignals__ = { + 'button-edit-copy': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([str, str])) + } + + _MIN_ITEMS = 2 + + def __init__(self): + ToolButton.__init__(self) + + self.props.tooltip = _('Copy to') + self.props.icon_name = 'edit-copy' + + self._empty_item = MenuItem(_('No options available')) + self._empty_item.set_sensitive(False) + self.props.palette.menu.insert(self._empty_item, -1) + self._empty_item.show() + + self._add_menuitem('activity-journal', _('the journal'), '/') + + monitor = gio.volume_monitor_get() + for mount in monitor.get_mounts(): + self._add_menuitem_mount(mount) + self._check_availability() + + self._mount_added_hid = monitor.connect('mount-added', + self.__mount_added_cb) + self._mount_removed_hid = monitor.connect('mount-removed', + self.__mount_removed_cb) + self.connect('clicked', self.__show_options_palette_cb) + + def __destroy_cb(self, button): + monitor = gio.volume_monitor_get() + monitor.disconnect(self._mount_added_hid) + monitor.disconnect(self._mount_removed_hid) + + def __mount_added_cb(self, monitor, mount): + self._add_menuitem_mount(mount) + self._check_availability() + + def __mount_removed_cb(self, monitor, mount): + mount_path = mount.get_root().get_path() + menu = self.props.palette.menu + for item in menu.get_children(): + if not isinstance(item, EditCopyItem): + continue + if mount_path == item.mount_path: + menu.remove(item) + self._check_availability() + + def __show_options_palette_cb(self, button): + self.props.palette.popup(immediate=True, state=1) + + def __copy_activated_cb(self, item): + self.emit('button-edit-copy', item.mount_info, item.mount_path) + + def _add_menuitem(self, icon_name, label, mount): + item = EditCopyItem(icon_name=icon_name, + text_label=label, + mount_path=mount) + item.connect('activate', self.__copy_activated_cb) + item.show() + self.props.palette.menu.insert(item, -1) + + def _add_menuitem_mount(self, mount): + icon_theme = gtk.icon_theme_get_default() + for name in mount.get_icon().props.names: + if icon_theme.has_icon(name): + icon_name=name + break + self._add_menuitem(icon_name, + mount.get_name(), + mount.get_root().get_path()) + + def _check_availability(self): + menu_items = self.props.palette.menu.get_children() + if len(menu_items) > self._MIN_ITEMS: + self._empty_item.hide() + else: + self._empty_item.show() + + def arrange_options(self, mount_path): + menu_items = self.props.palette.menu.get_children() + for item in menu_items: + if not isinstance(item, EditCopyItem): + continue + if mount_path == item.mount_path: + item.hide() + else: + item.show() + + +class EditCopyItem(MenuItem): + __gtype_name__ = 'JournalEditCopyItem' + + def __init__(self, icon_name, text_label, mount_path): + MenuItem.__init__(self, icon_name=icon_name, text_label=text_label) + self.mount_path = mount_path + self.mount_info = text_label + class SortingButton(ToolButton): __gtype_name__ = 'JournalSortingButton' diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py index 417ff61..833c823 100644 --- a/src/jarabe/journal/listmodel.py +++ b/src/jarabe/journal/listmodel.py @@ -40,6 +40,7 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): __gsignals__ = { 'ready': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), 'progress': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), + 'select': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([bool, bool])) } COLUMN_UID = 0 @@ -54,6 +55,7 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): COLUMN_BUDDY_1 = 9 COLUMN_BUDDY_2 = 10 COLUMN_BUDDY_3 = 11 + COLUMN_SELECT = 12 _COLUMN_TYPES = { COLUMN_UID: str, @@ -68,9 +70,10 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): COLUMN_BUDDY_1: object, COLUMN_BUDDY_3: object, COLUMN_BUDDY_2: object, + COLUMN_SELECT: bool } - _PAGE_SIZE = 10 + _PAGE_SIZE = 500 def __init__(self, query): gobject.GObject.__init__(self) @@ -80,6 +83,10 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): self._result_set = model.find(query, ListModel._PAGE_SIZE) self._temp_drag_file_path = None + # Multi-selection stuff + self._selection = set() + self._query_set_cache = set() + # HACK: The view will tell us that it is resizing so the model can # avoid hitting D-Bus and disk. self.view_is_resizing = False @@ -119,6 +126,11 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): return None if index == self._last_requested_index: + # HACK: avoid redrawing the whole view just for one row + selected = (self._cached_row[ListModel.COLUMN_UID] \ + in self._selection) + self._cached_row[ListModel.COLUMN_SELECT] = selected + return self._cached_row[column] if index >= self._result_set.length: @@ -197,6 +209,9 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): continue self._cached_row.append(None) + selected = (metadata['uid'] in self._selection) + self._cached_row.append(selected) + return self._cached_row[column] @@ -241,3 +256,45 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): return True return False + + def _get_query_set(self): + if self._query_set_cache: + return self._query_set_cache + query_set = set() + def collect(model, path, iter): + query_set.add(model[path][ListModel.COLUMN_UID]) + self.foreach(collect) + self._query_set_cache = query_set + return query_set + + def get_selection(self): + return self._selection.copy() + + def add_selection(self, selection): + if type(selection) is set: + query_set = self._get_query_set() + selection = selection.intersection(query_set) + self._selection = self._selection.union(selection) + self._emit_select() + + def set_selection_all(self): + query_set = self._get_query_set() + self._selection = self._selection.union(query_set) + self._emit_select(refresh_view=True) + + def set_selection_none(self): + self._selection = set() + self._emit_select(refresh_view=True) + + def toggle_selection(self, path): + uid = self[path][ListModel.COLUMN_UID] + if uid in self._selection: + self._selection.discard(uid) + else: + self._selection.add(uid) + self._emit_select() + + def _emit_select(self, refresh_view=False): + status = not (not self._selection) + self.emit('select', status, refresh_view) + diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py index 57836f2..ce71f54 100644 --- a/src/jarabe/journal/listview.py +++ b/src/jarabe/journal/listview.py @@ -64,6 +64,10 @@ class BaseListView(gtk.Bin): __gsignals__ = { 'clear-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), + 'clear-clicked': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([])), + 'select-toggled': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([bool])) } def __init__(self): @@ -115,6 +119,12 @@ class BaseListView(gtk.Bin): model.updated.connect(self.__model_updated_cb) model.deleted.connect(self.__model_deleted_cb) + # Multi-selection stuff + self._selection_cache = set() + + def get_mountpoint(self): + return self._query.get('mountpoints', [''])[0] + def __model_created_cb(self, sender, signal, object_id): if self._is_new_item_visible(object_id): self._set_dirty() @@ -134,10 +144,31 @@ class BaseListView(gtk.Bin): else: return object_id.startswith(self._query['mountpoints'][0]) + def __selected_cb(self, cell, path): + self._model.toggle_selection(path) + + def get_model(self): + return self._model + def _add_columns(self): + + cell_select = gtk.CellRendererToggle() + cell_select.props.indicator_size = style.zoom(26) + cell_select.props.activatable = True + cell_select.connect('toggled', self.__selected_cb) + + column = gtk.TreeViewColumn() + column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + column.props.fixed_width = style.GRID_CELL_SIZE + column.pack_start(cell_select) + column.add_attribute(cell_select, "active", ListModel.COLUMN_SELECT) + self.tree_view.append_column(column) + + cell_favorite = CellRendererFavorite(self.tree_view) cell_favorite.connect('clicked', self.__favorite_clicked_cb) + column = gtk.TreeViewColumn() column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED column.props.fixed_width = cell_favorite.props.width @@ -283,14 +314,34 @@ class BaseListView(gtk.Bin): if self._model is not None: self._model.stop() + self._manage_selection_cache() self._dirty = False self._model = ListModel(self._query) + self._model.connect('select', self.__model_select_cb) self._model.connect('ready', self.__model_ready_cb) self._model.connect('progress', self.__model_progress_cb) self._model.setup() + def _manage_selection_cache(self): + # Discard from cache elements that might not be selected anymore + self._selection_cache = \ + self._selection_cache.difference(self._model._query_set_cache) + # Add to cache elements that are selected + self._selection_cache = \ + self._selection_cache.union(self._model.get_selection()) + + def __model_select_cb(self, tree_model, status, refresh_view): + if refresh_view: + self._refresh_view(tree_model) + self.emit('select-toggled', status) + + def __model_ready_cb(self, tree_model): + self._model.add_selection(self._selection_cache) + self._refresh_view(tree_model) + + def _refresh_view(self, tree_model): self._stop_progress_bar() self._scroll_position = self.tree_view.props.vadjustment.props.value @@ -413,6 +464,8 @@ class BaseListView(gtk.Bin): self.add(self._scrolled_window) self._scrolled_window.show() + + def update_dates(self): if not self.tree_view.flags() & gtk.REALIZED: return diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py index 5285a7c..6897eeb 100644 --- a/src/jarabe/journal/model.py +++ b/src/jarabe/journal/model.py @@ -57,6 +57,16 @@ created = dispatch.Signal() updated = dispatch.Signal() deleted = dispatch.Signal() +_sync_signals_enabled = True +def _emit_created(object_id): + global _sync_signals_enabled + if _sync_signals_enabled: + created.send(None, object_id=object_id) + +def _emit_deleted(object_id): + global _sync_signals_enabled + if _sync_signals_enabled: + deleted.send(None, object_id=object_id) class _Cache(object): @@ -420,6 +430,43 @@ class InplaceResultSet(BaseResultSet): self._pending_files.append(dir_path + '/' + entry) return +def _set_signals_state(state, callback=None, data=None): + global _sync_signals_enabled + _sync_signals_enabled = state + if callback: + callback(data) + +def copy_entries(entries_set, mount_point): + _set_signals_state(False) + status, message = True, '' + for entry_uid in entries_set: + try: + metadata = get(entry_uid) + copy(metadata, mount_point) + except ValueError: + logging.warning('Entry %s has nothing to copied', entry_uid) + except (OSError, IOError): + status, message = False, _('No available space to continue') + break + gobject.idle_add(_set_signals_state, True) + return (status, message) + +def delete_entries(entries_set, mount_point): + _set_signals_state(False) + for entry_uid in entries_set: + try: + delete(entry_uid) + except (OSError, IOError): + logging.warning('Entry %s could not be deleted', entry_uid) + gobject.idle_add(_set_signals_state, True, + __post_delete_entries_cb, mount_point) + +def __post_delete_entries_cb(mount_point): + if mount_point is '/': + mount_point = 'abcde' + _emit_deleted(mount_point) + + def _get_file_metadata(path, stat, fetch_preview=True): """Return the metadata from the corresponding file. @@ -504,16 +551,14 @@ def _get_datastore(): def _datastore_created_cb(object_id): - created.send(None, object_id=object_id) - + _emit_created(object_id) def _datastore_updated_cb(object_id): updated.send(None, object_id=object_id) def _datastore_deleted_cb(object_id): - deleted.send(None, object_id=object_id) - + _emit_deleted(object_id) def find(query_, page_size): """Returns a ResultSet @@ -611,7 +656,7 @@ def delete(object_id): except EnvironmentError: logging.error('Could not remove metadata=%s ' 'for file=%s', old_file, filename) - deleted.send(None, object_id=object_id) + _emit_deleted(object_id) def copy(metadata, mount_point): @@ -746,7 +791,7 @@ def _write_entry_on_external_device(metadata, file_path): metadata_dir_path) object_id = destination_path - created.send(None, object_id=object_id) + _emit_created(object_id) return object_id -- cgit v0.9.1