# # Copyright 2011-2022 Martin Owens # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see # """ Wraps the gtk treeview and iconview in something a little nicer. """ import logging from typing import Tuple, Type, Optional from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, Pango from .pixmap import PixmapManager, SizeFilter GOBJ = GObject.TYPE_PYOBJECT def default(item, attr, d=None): """Python logic to choose an attribute, call it if required and return""" if hasattr(item, attr): prop = getattr(item, attr) if callable(prop): prop = prop() return prop return d def cmp(a, b): """Compare two objects""" return (a > b) - (a < b) def item_property(name, d=None): def inside(item): return default(item, name, d) return inside def label(obj): if isinstance(obj, tuple): return " or ".join([label(o) for o in obj]) if not isinstance(obj, type): obj = type(obj) return obj.__name__ class BaseView: """Controls for tree and icon views, a base class""" widget_type: Optional[Type[Gtk.Widget]] = None def __init__(self, widget, liststore=None, **kwargs): if not isinstance(widget, self.widget_type): lbl1 = label(self.widget_type) lbl2 = label(widget) raise TypeError(f"Wrong widget type: Expected {lbl1} got {lbl2}") self.selected_signal = kwargs.get("selected", None) self._iids = [] self._list = widget self.args = kwargs self.selected = None self._data = None self.no_dupes = True self._model = self.create_model(liststore or widget.get_model()) self._list.set_model(self._model) self.setup() self._list.connect(self.changed_signal, self.item_selected_signal) def get_model(self): """Returns the current data store model""" return self._model def create_model(self, liststore): """Setup the model and list""" if not isinstance(liststore, (Gtk.ListStore, Gtk.TreeStore)): lbl = label(liststore) raise TypeError(f"Expected List or TreeStore, got {lbl}") return liststore def refresh(self): """Attempt to refresh the listview""" self._list.queue_draw() def setup(self): """Setup columns, views, sorting etc""" pass def get_item_id(self, item): """ Return an id set against this item. If item.get_id() is set then duplicates will be ignored. """ if hasattr(item, "get_id"): return item.get_id() return None def replace(self, new_item, item_iter=None): """Replace all items, or a single item with object""" if item_iter: self.remove_item(item_iter) self.add_item(new_item) else: self.clear() self._data = new_item self.add_item(new_item) def item_selected(self, item=None, *others): """Base method result, called as an item is selected""" if self.selected != item: self.selected = item if self.selected_signal and item: self.selected_signal(item) def remove_item(self, item=None): """Remove an item from this view""" return self._model.remove(self.get_iter(item)) def check_item_id(self, item): """Item id is recorded to guard against duplicates""" iid = self.get_item_id(item) if iid in self._iids and self.no_dupes: raise ValueError(f"Will not add duplicate row {iid}") if iid: self._iids.append(iid) def __iter__(self): ret = [] def collect_all(store, treepath, treeiter): ret.append((self.get_item(treeiter), treepath, treeiter)) self._model.foreach(collect_all) return ret.__iter__() def set_sensitive(self, sen=True): """Proxy the GTK property for sensitivity""" self._list.set_sensitive(sen) def clear(self): """Clear all items from this treeview""" self._iids = [] self._model.clear() def item_double_clicked(self, *items): """What happens when you double click an item""" return items # Nothing def get_item(self, item_iter): """Return the object of attention from an iter""" return self._model[self.get_iter(item_iter)][0] def get_iter(self, item, path=False): """Return the iter given the item""" if isinstance(item, Gtk.TreePath): return item if path else self._model.get_iter(item) if isinstance(item, Gtk.TreeIter): return self._model.get_path(item) if path else item for src_item, src_path, src_iter in self: if item == src_item: return src_path if path else src_iter return None class TreeView(BaseView): """Controls and operates a tree view.""" column_size = 16 widget_type = Gtk.TreeView changed_signal = "cursor_changed" def setup(self): """Setup the treeview""" self._sel = self._list.get_selection() self._sel.set_mode(Gtk.SelectionMode.MULTIPLE) self._list.connect("button-press-event", self.item_selected_signal) # Separators should do something self._list.set_row_separator_func(TreeView.is_separator, None) super().setup() @staticmethod def is_separator(model, item_iter, data): """Internal function for seperator checking""" return isinstance(model.get_value(item_iter, 0), Separator) def get_selected_items(self): """Return a list of selected item objects""" return [self.get_item(row) for row in self._sel.get_selected_rows()[1]] def set_selected_items(self, *items): """Select the given items""" self._sel.unselect_all() for item in items: path_item = self.get_iter(item, path=True) if path_item is not None: self._sel.select_path(path_item) def is_selected(self, item): """Return true if the item is selected""" return self._sel.iter_is_selected(self.get_iter(item)) def add(self, target, parent=None): """Add all items from the target to the treeview""" for item in target: self.add_item(item, parent=parent) def add_item(self, item, parent=None): """Add a single item image to the control, returns the TreePath""" if item is not None: self.check_item_id(item) return self._add_item([item], self.get_iter(parent)) raise ValueError("Item can not be None.") def _add_item(self, item, parent): return self.get_iter(self._model.append(parent, item), path=True) def item_selected_signal(self, *args, **kwargs): """Signal for selecting an item""" return self.item_selected(*self.get_selected_items()) def item_button_clicked(self, _, event): """Signal for mouse button click""" if event is None or event.type == Gdk.EventType._2BUTTON_PRESS: self.item_double_clicked(*self.get_selected_items()) def expand_item(self, item, expand=True): """Expand one of our nodes""" self._list.expand_row(self.get_iter(item, path=True), expand) def create_model(self, liststore=None): """Set up an icon view for showing gallery images""" if liststore is None: liststore = Gtk.TreeStore(GOBJ) return super().create_model(liststore) def create_column(self, name, expand=True): """ Create and pack a new column to this list. name - Label in the column header expand - Should the column expand """ return ViewColumn(self._list, name, expand=expand) def create_sort(self, *args, **kwargs): """ Create and attach a sorting view to this list. see ViewSort arguments for details. """ return ViewSort(self._list, *args, **kwargs) class ComboBox(TreeView): """Controls and operates a combo box list.""" widget_type = Gtk.ComboBox changed_signal = "changed" def setup(self): pass def get_selected_item(self): """Return the selected item of this combo box""" return self.get_item(self._list.get_active_iter()) def set_selected_item(self, item): """Set the given item as the selected item""" self._list.set_active_iter(self.get_iter(item)) def is_selected(self, item): """Returns true if this item is the selected item""" return self.get_selected_item() == item def get_selected_items(self): """Return a list of selected items (one)""" return [self.get_selected_item()] class IconView(BaseView): """Allows a simpler IconView for DBus List Objects""" widget_type = Gtk.IconView changed_signal = "selection-changed" def __init__(self, widget, pixmaps, *args, **kwargs): super().__init__(widget, *args, **kwargs) self.pixmaps = pixmaps def set_selected_item(self, item): """Sets the selected item to this item""" path = self.get_iter(item, path=True) if path: self._list.set_cursor(path, None, False) def get_selected_items(self): """Return the seleced item""" return [self.get_item(path) for path in self._list.get_selected_items()] def create_model(self, liststore): """Setup the icon view control and model""" if not liststore: liststore = Gtk.ListStore(GOBJ, str, GdkPixbuf.Pixbuf) return super().create_model(liststore) def setup(self): """Setup the columns for the iconview""" self._list.set_markup_column(1) self._list.set_pixbuf_column(2) super().setup() def add(self, target): """Add all items from the target to the iconview""" for item in target: self.add_item(item) def add_item(self, item): """Add a single item image to the control""" if item is not None: self.check_item_id(item) return self._add_item(item) raise ValueError("Item can not be None.") def get_markup(self, item): """Default text return for markup.""" return default(item, "name", str(item)) def get_icon(self, item): """Default icon return, pixbuf or gnome theme name""" return default(item, "icon", None) def _get_icon(self, item): return self.pixmaps.get(self.get_icon(item), item=item) def _add_item(self, item): """ Each item's properties must be stuffed into the ListStore directly or the IconView won't see them, but only if on auto. """ if not isinstance(item, (tuple, list)): item = [item, self.get_markup(item), self._get_icon(item)] return self._model.append(item) def item_selected_signal(self, *args, **kwargs): """Item has been selected""" return self.item_selected(*self.get_selected_items()) class ViewSort(object): """ A sorting function for use is ListViews ascending - Boolean which direction to sort contains - Contains this string data - A string or function to get data from each item. exact - Compare to this exact string instead. """ def __init__(self, widget, data=None, ascending=False, exact=None, contains=None): self.tree = None self.data = data self.asc = ascending self.comp = exact.lower() if exact else None self.cont = contains self.tree = widget self.resort() def get_data(self, model, list_iter): """Generate sortable data from the item""" item = model.get_value(list_iter, 0) if isinstance(self.data, str): value = getattr(item, self.data) elif callable(self.data): value = self.data(item) return value def sort_func(self, model, iter1, iter2, data): """Called by Gtk to sort items""" value1 = self.get_data(model, iter1) value2 = self.get_data(model, iter2) if value1 == None or value2 == None: return 0 if self.comp: if cmp(self.comp, value1.lower()) == 0: return 1 elif cmp(self.comp, value2.lower()) == 0: return -1 return 0 elif self.cont: if self.cont in value1.lower(): return 1 elif self.cont in value2.lower(): return -1 return 0 if value1 < value2: return 1 if value2 < value1: return -1 return 0 def resort(self): model = self.tree.get_model() model.set_sort_func(0, self.sort_func, None) if self.asc: model.set_sort_column_id(0, Gtk.SortType.ASCENDING) else: model.set_sort_column_id(0, Gtk.SortType.DESCENDING) class ViewColumn(object): """ Add a column to a gtk treeview. name - The column name used as a label. expand - Set column expansion. """ def __init__(self, widget, name, expand=False): if isinstance(widget, Gtk.TreeView): column = Gtk.TreeViewColumn((name)) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column.set_expand(expand) self._column = column widget.append_column(self._column) else: # Deal with possible drop down lists self._column = widget def add_renderer(self, renderer, func, expand=True): """Set a custom renderer""" self._column.pack_start(renderer, expand) self._column.set_cell_data_func(renderer, func, None) return renderer def add_image_renderer(self, icon, pad=0, pixmaps=None, size=None): """ Set the image renderer icon - The function that returns the image to be dsplayed. pad - The amount of padding around the image. pixmaps - The pixmap manager to use to get images. size - Restrict the images to this size. """ # Manager where icons will be pulled from filters = [SizeFilter] if size else [] pixmaps = pixmaps or PixmapManager( "", pixmap_dir="./", filters=filters, size=size ) renderer = Gtk.CellRendererPixbuf() renderer.set_property("ypad", pad) renderer.set_property("xpad", pad) func = self.image_func(icon or self.default_icon, pixmaps) return self.add_renderer(renderer, func, expand=False) def add_text_renderer(self, text, wrap=None, template=None): """ Set the text renderer. text - the function that returns the text to be displayed. wrap - The wrapping setting for this renderer. template - A standard template used for this text markup. """ renderer = Gtk.CellRendererText() if wrap is not None: renderer.props.wrap_width = wrap renderer.props.wrap_mode = Pango.WrapMode.WORD renderer.props.background_set = True renderer.props.foreground_set = True func = self.text_func(text or self.default_text, template) return self.add_renderer(renderer, func, expand=True) @classmethod def clean(cls, text, markup=False): """Clean text of any pango markup confusing chars""" if text is None: text = "" if isinstance(text, (str, int, float)): if markup: text = str(text).replace("<", "<").replace(">", ">") return str(text).replace("&", "&") elif isinstance(text, dict): return dict([(k, cls.clean(v)) for k, v in text.items()]) elif isinstance(text, (list, tuple)): return tuple([cls.clean(value) for value in text]) raise TypeError("Unknown value type for text: %s" % str(type(text))) def get_callout(self, call, default=None): """Returns the right kind of method""" if isinstance(call, str): call = item_property(call, default) return call def text_func(self, call, template=None): """Wrap up our text functionality""" callout = self.get_callout(call) def internal(column, cell, model, item_iter, data): if TreeView.is_separator(model, item_iter, data): return item = model.get_value(item_iter, 0) markup = template is not None text = callout(item) if isinstance(template, str): text = template.format(self.clean(text, markup=True)) else: text = self.clean(text) cell.set_property("markup", str(text)) return internal def image_func(self, call, pixmaps=None): """Wrap, wrap wrap the func""" callout = self.get_callout(call) def internal(column, cell, model, item_iter, data): if TreeView.is_separator(model, item_iter, data): return item = model.get_value(item_iter, 0) icon = callout(item) # The or blank asks for the default icon from the pixmaps if isinstance(icon or "", str) and pixmaps: # Expect a Gnome theme icon icon = pixmaps.get(icon) elif icon: icon = pixmaps.apply_filters(icon) cell.set_property("pixbuf", icon) cell.set_property("visible", True) return internal def default_text(self, item): """Default text return for markup.""" return default(item, "name", str(item)) def default_icon(self, item): """Default icon return, pixbuf or gnome theme name""" return default(item, "icon", None) class Separator: """Reprisentation of a separator in a list"""