diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-10 21:38:38 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-10 21:38:38 +0000 |
commit | 2e2851dc13d73352530dd4495c7e05603b2e520d (patch) | |
tree | 622b9cd8e5d32091c9aa9e4937b533975a40356c /deluge/ui/gtk3/listview.py | |
parent | Initial commit. (diff) | |
download | deluge-2e2851dc13d73352530dd4495c7e05603b2e520d.tar.xz deluge-2e2851dc13d73352530dd4495c7e05603b2e520d.zip |
Adding upstream version 2.1.2~dev0+20240219.upstream/2.1.2_dev0+20240219upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'deluge/ui/gtk3/listview.py')
-rw-r--r-- | deluge/ui/gtk3/listview.py | 831 |
1 files changed, 831 insertions, 0 deletions
diff --git a/deluge/ui/gtk3/listview.py b/deluge/ui/gtk3/listview.py new file mode 100644 index 0000000..a80d795 --- /dev/null +++ b/deluge/ui/gtk3/listview.py @@ -0,0 +1,831 @@ +# +# Copyright (C) 2007, 2008 Andrew Resch <andrewresch@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging + +from gi.repository import GObject, Gtk + +from deluge.common import decode_bytes + +from .common import cmp, load_pickled_state_file, save_pickled_state_file + +log = logging.getLogger(__name__) + + +class ListViewColumnState: + """Class used for saving/loading column state.""" + + def __init__(self, name, position, width, visible, sort, sort_order): + self.name = name + self.position = position + self.width = width + self.visible = visible + self.sort = sort + self.sort_order = sort_order + + +class ListView: + """ListView is used to make custom GtkTreeViews. It supports the adding + and removing of columns, creating a menu for a column toggle list and + support for 'status_field's which are used while updating the columns data. + """ + + class ListViewColumn: + """Holds information regarding a column in the ListView""" + + def __init__(self, name, column_indices): + # Name is how a column is identified and is also the header + self.name = name + # Column_indices holds the indexes to the liststore_columns that + # this column utilizes. It is stored as a list. + self.column_indices = column_indices + # Column is a reference to the GtkTreeViewColumn object + self.column = None + # This is the name of the status field that the column will query + # the core for if an update is called. + self.status_field = None + # If column is 'hidden' then it will not be visible and will not + # show up in any menu listing; it cannot be shown ever. + self.hidden = False + # If this is set, it is used to sort the model + self.sort_func = None + self.sort_id = None + # Values needed to update TreeViewColumns + self.column_type = None + self.renderer = None + self.text_index = 0 + self.value_index = 0 + self.pixbuf_index = 0 + self.data_func = None + + class TreeviewColumn(Gtk.TreeViewColumn): + """ + TreeViewColumn does not signal right-click events, and we need them + This subclass is equivalent to TreeViewColumn, but it signals these events + + Most of the code of this class comes from Quod Libet (http://www.sacredchao.net/quodlibet) + """ + + __gsignals__ = { + 'button-press-event': (GObject.SignalFlags.RUN_LAST, None, (object,)) + } + + def __init__(self, title=None, cell_renderer=None, **args): + """Constructor, see Gtk.TreeViewColumn""" + Gtk.TreeViewColumn.__init__(self, title, cell_renderer, **args) + label = Gtk.Label(label=title) + self.set_widget(label) + label.show() + label.__realize = label.connect('realize', self.on_realize) + self.title = title + self.data_func = None + self.data_func_data = None + self.cell_renderer = None + + def on_realize(self, widget): + widget.disconnect(widget.__realize) + del widget.__realize + button = widget.get_ancestor(Gtk.Button) + if button is not None: + button.connect('button-press-event', self.on_button_pressed) + + def on_button_pressed(self, widget, event): + self.emit('button-press-event', event) + + def set_cell_data_func_attributes(self, cell_renderer, func, func_data=None): + """Store the values to be set by set_cell_data_func""" + self.data_func = func + self.data_func_data = func_data + self.cell_renderer = cell_renderer + + def set_visible(self, visible): + Gtk.TreeViewColumn.set_visible(self, visible) + if self.data_func: + if not visible: + # Set data function to None to prevent unnecessary calls when column is hidden + self.set_cell_data_func(self.cell_renderer, None, func_data=None) + else: + self.set_cell_data_func( + self.cell_renderer, self.data_func, self.data_func_data + ) + + def set_col_attributes(self, renderer, add=True, **kw): + if add is True: + for attr, value in kw.items(): + self.add_attribute(renderer, attr, value) + else: + self.set_attributes(renderer, **kw) + + def __init__(self, treeview_widget=None, state_file=None): + log.debug('ListView initialized..') + + if treeview_widget is not None: + # User supplied a treeview widget + self.treeview = treeview_widget + else: + self.treeview = Gtk.TreeView() + + self.treeview.set_enable_search(True) + self.treeview.set_search_equal_func(self.on_keypress_search_by_name, None) + + if state_file: + self.load_state(state_file) + + self.liststore = None + self.model_filter = None + + self.treeview.set_reorderable(False) + self.treeview.set_rubber_banding(True) # Enable mouse multi-row selection. + self.treeview.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + + # Dictionary of 'header' or 'name' to ListViewColumn object + self.columns = {} + # Column_index keeps track of the order of the visible columns. + self.column_index = [] + # The column types for the list store.. this may have more entries than + # visible columns due to some columns utilizing more than 1 liststore + # column and some columns being hidden. + self.liststore_columns = [] + # The GtkMenu that is created after every addition, removal or reorder + self.menu = None + # A list of menus that self.menu will be a submenu of everytime it is + # created. + self.checklist_menus = [] + + # Store removed columns state. This is needed for plugins that remove + # their columns prior to having the state list saved on shutdown. + self.removed_columns_state = [] + + # Since gtk TreeModelSort doesn't do stable sort, remember last sort order so we can + self.last_sort_order = {} + self.unique_column_id = None + self.default_sort_column_id = None + + # Create the model filter and column + self.add_bool_column('filter', hidden=True) + + def create_model_filter(self): + """create new filter-model + must be called after listview.create_new_liststore + """ + model_filter = self.liststore.filter_new() + model_filter.set_visible_column(self.columns['filter'].column_indices[0]) + self.model_filter = Gtk.TreeModelSort(model=model_filter) + self.model_filter.connect('sort-column-changed', self.on_model_sort_changed) + self.model_filter.connect('row-inserted', self.on_model_row_inserted) + self.treeview.set_model(self.model_filter) + self.set_sort_functions() + self.set_model_sort() + + def set_model_sort(self): + column_state = self.get_sort_column_from_state() + if column_state: + self.treeview.get_model().set_sort_column_id( + column_state.sort, column_state.sort_order + ) + # Using the default sort column + elif self.default_sort_column_id: + self.model_filter.set_sort_column_id( + self.default_sort_column_id, Gtk.SortType.ASCENDING + ) + self.model_filter.set_default_sort_func( + self.generic_sort_func, self.get_column_index(_('Added'))[0] + ) + + def get_sort_column_from_state(self): + """Find the first (should only be one) state with sort enabled""" + if self.state is None: + return None + for column_state in self.state: + if column_state.sort is not None and column_state.sort > -1: + return column_state + return None + + def on_model_sort_changed(self, model): + if self.unique_column_id: + self.last_sort_order = {} + + def record_position(model, path, _iter, data): + unique_id = model[_iter][self.unique_column_id] + self.last_sort_order[unique_id] = int(str(path)) + + model.foreach(record_position, None) + + def on_model_row_inserted(self, model, path, _iter): + if self.unique_column_id: + self.last_sort_order.setdefault( + model[_iter][self.unique_column_id], len(model) - 1 + ) + + def stabilize_sort_func(self, sort_func): + def stabilized(model, iter1, iter2, data): + result = sort_func(model, iter1, iter2, data) + if result == 0 and self.unique_column_id: + unique1 = model[iter1][self.unique_column_id] + unique2 = model[iter2][self.unique_column_id] + if unique1 in self.last_sort_order and unique2 in self.last_sort_order: + result = cmp( + self.last_sort_order[unique1], self.last_sort_order[unique2] + ) + # If all else fails, fall back to sorting by unique column + if result == 0: + result = cmp(unique1, unique2) + + return result + + return stabilized + + def generic_sort_func(self, model, iter1, iter2, data): + return cmp(model[iter1][data], model[iter2][data]) + + def set_sort_functions(self): + for column in self.columns.values(): + sort_func = column.sort_func or self.generic_sort_func + self.model_filter.set_sort_func( + column.sort_id, self.stabilize_sort_func(sort_func), column.sort_id + ) + + def create_column_state(self, column, position=None): + if not position: + # Find the position + for index, c in enumerate(self.treeview.get_columns()): + if column.get_title() == c.get_title(): + position = index + break + sort = None + if self.model_filter: + sort_id, order = self.model_filter.get_sort_column_id() + col_title = decode_bytes(column.get_title()) + if self.get_column_name(sort_id) == col_title: + sort = sort_id + + return ListViewColumnState( + column.get_title(), + position, + column.get_width(), + column.get_visible(), + sort, + int(column.get_sort_order()), + ) + + def save_state(self, filename): + """Saves the listview state (column positions and visibility) to + filename.""" + # A list of ListViewColumnStates + state = [] + + # Workaround for all zero widths after removing column on shutdown + if not any(c.get_width() for c in self.treeview.get_columns()): + return + + # Get the list of TreeViewColumns from the TreeView + for counter, column in enumerate(self.treeview.get_columns()): + # Append a new column state to the state list + state.append(self.create_column_state(column, counter)) + + state += self.removed_columns_state + + self.state = state + save_pickled_state_file(filename, state) + + def load_state(self, filename): + """Load the listview state from filename.""" + self.state = load_pickled_state_file(filename) + + def set_treeview(self, treeview_widget): + """Set the treeview widget that this listview uses.""" + self.treeview = treeview_widget + self.treeview.set_model(self.liststore) + return + + def get_column_index(self, name): + """Get the liststore column indices belonging to this column. + Will return a list. + """ + return self.columns[name].column_indices + + def get_column_name(self, index): + """Get the header name for a liststore column index""" + for key, value in self.columns.items(): + if index in value.column_indices: + return key + + def get_state_field_column(self, field): + """Returns the column number for the state field""" + for column in self.columns: + if self.columns[column].status_field is None: + continue + + for f in self.columns[column].status_field: + if field == f: + return self.columns[column].column_indices[ + self.columns[column].status_field.index(f) + ] + + def on_menuitem_toggled(self, widget): + """Callback for the generated column menuitems.""" + # Get the column name from the widget + name = widget.get_child().get_text() + + # Set the column's visibility based on the widgets active state + try: + self.columns[name].column.set_visible(widget.get_active()) + except KeyError: + self.columns[decode_bytes(name)].column.set_visible(widget.get_active()) + return + + def on_treeview_header_right_clicked(self, column, event): + if event.button == 3: + self.menu.popup(None, None, None, None, event.button, event.get_time()) + + def register_checklist_menu(self, menu): + """Register a checklist menu with the listview. It will automatically + attach any new checklist menu it makes to this menu. + """ + self.checklist_menus.append(menu) + + def create_checklist_menu(self): + """Creates a menu used for toggling the display of columns.""" + menu = self.menu = Gtk.Menu() + # Iterate through the column_index list to preserve order + for name in self.column_index: + column = self.columns[name] + # If the column is hidden, then we do not want to show it in the + # menu. + if column.hidden is True: + continue + menuitem = Gtk.CheckMenuItem.new_with_label(column.name) + # If the column is currently visible, make sure it's set active + # (or checked) in the menu. + if column.column.get_visible() is True: + menuitem.set_active(True) + # Connect to the 'toggled' event + menuitem.connect('toggled', self.on_menuitem_toggled) + # Add the new checkmenuitem to the menu + menu.append(menuitem) + + # Attach this new menu to all the checklist_menus + for _menu in self.checklist_menus: + _menu.set_submenu(menu) + _menu.show_all() + return menu + + def create_new_liststore(self): + """Creates a new GtkListStore based on the liststore_columns list""" + # Create a new liststore with added column and move the data from the + # old one to the new one. + new_list = Gtk.ListStore(*tuple(self.liststore_columns)) + + # This function is used in the liststore.foreach method with user_data + # being the new liststore and the columns list + def copy_row(model, path, row, user_data): + new_list, columns = user_data + new_row = new_list.append() + for column in range(len(columns)): + # Get the current value of the column for this row + value = model.get_value(row, column) + # Set the value of this row and column in the new liststore + new_list.set_value(new_row, column, value) + + # Do the actual row copy + if self.liststore is not None: + self.liststore.foreach(copy_row, (new_list, self.columns)) + + self.liststore = new_list + + def update_treeview_column(self, header, add=True): + """Update TreeViewColumn based on ListView column mappings""" + column = self.columns[header] + tree_column = self.columns[header].column + + if column.column_type == 'text': + if add: + tree_column.pack_start(column.renderer, True) + tree_column.set_col_attributes( + column.renderer, add=add, text=column.column_indices[column.text_index] + ) + elif column.column_type == 'bool': + if add: + tree_column.pack_start(column.renderer, True) + tree_column.set_col_attributes( + column.renderer, active=column.column_indices[0] + ) + elif column.column_type == 'func': + if add: + tree_column.pack_start(column.renderer, True) + indice_arg = column.column_indices[0] + if len(column.column_indices) > 1: + indice_arg = tuple(column.column_indices) + tree_column.set_cell_data_func( + column.renderer, column.data_func, indice_arg + ) + elif column.column_type == 'progress': + if add: + tree_column.pack_start(column.renderer, True) + if column.data_func is None: + tree_column.set_col_attributes( + column.renderer, + add=add, + text=column.column_indices[column.text_index], + value=column.column_indices[column.value_index], + ) + else: + tree_column.set_cell_data_func( + column.renderer, column.data_func, tuple(column.column_indices) + ) + elif column.column_type == 'texticon': + if add: + tree_column.pack_start(column.renderer[column.pixbuf_index], False) + tree_column.pack_start(column.renderer[column.text_index], True) + tree_column.set_col_attributes( + column.renderer[column.text_index], + add=add, + text=column.column_indices[column.text_index], + ) + if column.data_func is not None: + tree_column.set_cell_data_func( + column.renderer[column.pixbuf_index], + column.data_func, + column.column_indices[column.pixbuf_index], + ) + return True + + def remove_column(self, header): + """Removes the column with the name 'header' from the listview""" + # Store a copy of this columns state in case it's re-added + state = self.create_column_state(self.columns[header].column) + self.removed_columns_state.append(state) + # Only remove column if column is associated with the treeview. This avoids + # warning on shutdown when GTKUI is closed before plugins try to remove columns + if self.columns[header].column.get_tree_view() is not None: + self.treeview.remove_column(self.columns[header].column) + # Get the column indices + column_indices = self.columns[header].column_indices + # Delete the column + del self.columns[header] + self.column_index.remove(header) + # Shift the column_indices values of those columns affected by the + # removal. Any column_indices > the one removed. + for column in self.columns.values(): + if column.column_indices[0] > column_indices[0]: + # We need to shift this column_indices + for i, index in enumerate(column.column_indices): + column.column_indices[i] = index - len(column_indices) + # Update the associated TreeViewColumn + self.update_treeview_column(column.name, add=False) + + # Remove from the liststore columns list + for index in sorted(column_indices, reverse=True): + del self.liststore_columns[index] + + # Create a new liststore + self.create_new_liststore() + # Create new model for the treeview + self.create_model_filter() + + # Re-create the menu + self.create_checklist_menu() + return + + def add_column( + self, + header, + render, + col_types, + hidden, + position, + status_field, + sortid, + text=0, + value=0, + pixbuf=0, + function=None, + column_type=None, + sort_func=None, + tooltip=None, + default=True, + unique=False, + default_sort=False, + ): + """Adds a column to the ListView""" + # Add the column types to liststore_columns + column_indices = [] + if isinstance(col_types, list): + for col_type in col_types: + self.liststore_columns.append(col_type) + column_indices.append(len(self.liststore_columns) - 1) + else: + self.liststore_columns.append(col_types) + column_indices.append(len(self.liststore_columns) - 1) + + # Add to the index list so we know the order of the visible columns. + if position is not None: + self.column_index.insert(position, header) + else: + self.column_index.append(header) + + # Create a new column object and add it to the list + column = self.TreeviewColumn(header) + self.columns[header] = self.ListViewColumn(header, column_indices) + self.columns[header].column = column + self.columns[header].status_field = status_field + self.columns[header].sort_func = sort_func + self.columns[header].sort_id = column_indices[sortid] + # Store creation details + self.columns[header].column_type = column_type + self.columns[header].renderer = render + self.columns[header].text_index = text + self.columns[header].value_index = value + self.columns[header].pixbuf_index = pixbuf + self.columns[header].data_func = function + + if unique: + self.unique_column_id = column_indices[sortid] + if default_sort: + self.default_sort_column_id = column_indices[sortid] + + # Create a new list with the added column + self.create_new_liststore() + + # Happens only on columns added after the torrent list has been loaded + if self.model_filter: + self.create_model_filter() + + if column_type is None: + return + + self.update_treeview_column(header) + + column.set_sort_column_id(self.columns[header].column_indices[sortid]) + column.set_clickable(True) + column.set_resizable(True) + column.set_expand(False) + column.set_min_width(20) + column.set_reorderable(True) + column.set_visible(not hidden) + column.connect('button-press-event', self.on_treeview_header_right_clicked) + + if tooltip: + column.get_widget().set_tooltip_markup(tooltip) + + # Check for loaded state and apply + column_in_state = False + if self.state is not None: + for column_state in self.state: + if header == decode_bytes(column_state.name): + # We found a loaded state + column_in_state = True + if column_state.width > 0: + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + column.set_fixed_width(column_state.width) + + column.set_visible(column_state.visible) + position = column_state.position + break + + # Set this column to not visible if its not in the state and + # its not supposed to be shown by default + if not column_in_state and not default and not hidden: + column.set_visible(False) + + if position is not None: + self.treeview.insert_column(column, position) + else: + self.treeview.append_column(column) + + # Set hidden in the column + self.columns[header].hidden = hidden + self.columns[header].column = column + # Re-create the menu item because of the new column + self.create_checklist_menu() + + return True + + def add_text_column( + self, + header, + col_type=str, + hidden=False, + position=None, + status_field=None, + sortid=0, + column_type='text', + sort_func=None, + tooltip=None, + default=True, + unique=False, + default_sort=False, + ): + """Add a text column to the listview. Only the header name is required.""" + render = Gtk.CellRendererText() + self.add_column( + header, + render, + col_type, + hidden, + position, + status_field, + sortid, + column_type=column_type, + sort_func=sort_func, + tooltip=tooltip, + default=default, + unique=unique, + default_sort=default_sort, + ) + + return True + + def add_bool_column( + self, + header, + col_type=bool, + hidden=False, + position=None, + status_field=None, + sortid=0, + column_type='bool', + tooltip=None, + default=True, + ): + """Add a bool column to the listview""" + render = Gtk.CellRendererToggle() + self.add_column( + header, + render, + col_type, + hidden, + position, + status_field, + sortid, + column_type=column_type, + tooltip=tooltip, + default=default, + ) + + def add_func_column( + self, + header, + function, + col_types, + sortid=0, + hidden=False, + position=None, + status_field=None, + column_type='func', + sort_func=None, + tooltip=None, + default=True, + ): + """Add a function column to the listview. Need a header name, the + function and the column types.""" + + render = Gtk.CellRendererText() + self.add_column( + header, + render, + col_types, + hidden, + position, + status_field, + sortid, + column_type=column_type, + function=function, + sort_func=sort_func, + tooltip=tooltip, + default=default, + ) + + return True + + def add_progress_column( + self, + header, + col_types=None, + sortid=0, + hidden=False, + position=None, + status_field=None, + function=None, + column_type='progress', + tooltip=None, + sort_func=None, + default=True, + ): + """Add a progress column to the listview.""" + + if col_types is None: + col_types = [float, str] + render = Gtk.CellRendererProgress() + self.add_column( + header, + render, + col_types, + hidden, + position, + status_field, + sortid, + function=function, + column_type=column_type, + value=0, + text=1, + tooltip=tooltip, + sort_func=sort_func, + default=default, + ) + + return True + + def add_texticon_column( + self, + header, + col_types=None, + sortid=1, + hidden=False, + position=None, + status_field=None, + column_type='texticon', + function=None, + sort_func=None, + tooltip=None, + default=True, + default_sort=False, + ): + """Adds a texticon column to the listview.""" + if col_types is None: + col_types = [str, str] + render1 = Gtk.CellRendererPixbuf() + render2 = Gtk.CellRendererText() + + self.add_column( + header, + (render1, render2), + col_types, + hidden, + position, + status_field, + sortid, + column_type=column_type, + function=function, + pixbuf=0, + text=1, + tooltip=tooltip, + sort_func=sort_func, + default=default, + default_sort=default_sort, + ) + + return True + + def on_keypress_search_by_name(self, model, column, key, _iter): + torrent_name_col = self.columns[_('Name')].column_indices[1] + return not model[_iter][torrent_name_col].lower().startswith(key.lower()) + + def restore_columns_order_from_state(self): + if self.state is None: + # No state file exists, so, no reordering can be done + return + columns = self.treeview.get_columns() + + def find_column(header): + for column in columns: + if column.get_title() == header: + return column + + restored_columns = [] + for col_state in self.state: + if col_state.name in restored_columns: + # Duplicate column in state!?!?!? + continue + elif not col_state.visible: + # Column is not visible, no need to reposition + continue + + try: + column_at_position = columns[col_state.position] + except IndexError: + # Ignore extra columns from Plugins in col_state + continue + if col_state.name == column_at_position.get_title(): + # It's in the right position + continue + column = find_column(col_state.name) + if not column: + log.debug( + 'Could not find column matching "%s" on state.', col_state.name + ) + # The cases where I've found that the column could not be found + # is when not using the english locale, ie, the default one, or + # when changing locales between runs. + # On the next load, all should be fine + continue + self.treeview.move_column_after(column, column_at_position) + # Get columns again to keep reordering since positions have changed + columns = self.treeview.get_columns() + restored_columns.append(col_state.name) + self.create_new_liststore() |