How to filter a GTK tree view that uses a TreeStore (and not a ListStore)?
Asked Answered
G

1

6

I am using a Gtk.TreeView with a Gtk.TreeStore as a model for hierarchical data. As an example, let's take a music database organized into three levels: artist/album/title. I would like to filter this tree using a textual search field. For example, typing "Five" into the search field should give a result along the path "Hank Marvin/Heartbeat/Take Five".

My understanding is that I need to create a callback function and register it using Gtk.TreeModelFilter.set_visible_func(). The problem is that making the line for "Take Five" visible is not enough to make it appear, I also have to set all of its parents visible as well. However, that would require me to traverse the tree up to its root and actively make each node visible along that path, which does not fit into the callback pattern.

One way I see to make this logic work with the callback pattern is to check the whole subtree in the callback function, but that way each leaf node would get checked three times. Even though the performance penalty would be acceptable with such a shallow tree, this hack gives me the goosebumps and I would like to refrain from using it:

def visible_callback(self, model, iter, _data=None):
    search_query = self.entry.get_text().lower()
    if search_query == "":
        return True

    text = model.get_value(iter, 0).lower()
    if search_query in text:
        return True

    # Horrible hack
    for i in range(model.iter_n_children(iter)):
        if self.visible_callback(model, model.iter_nth_child(iter, i)):
            return True

    return False

What is the intended way to filter a tree view in GTK? (My example is written in Python, but a solution for any language binding of GTK would be fine.)

Gaygaya answered 7/5, 2019 at 20:5 Comment(6)
It looks like GtkTreeModelFilter should explicitly support what you're trying to do. Have you checked the documentation here: developer.gnome.org/gtk3/stable/GtkTreeModelFilter.html. Specifically, start in the description with the section starting "Determining the visibility state of a given node based on the state of its child nodes is a frequently occurring use case"...Abscond
Hmm, that is my use case mentioned in the docs, indeed. Unfortunately from that description it's unclear to me what needs to be done for this to work and haven't found any examples. It certainly does not happen automatically, because the callback function in my question does not work without the hacky part.Gaygaya
Ok, I see - I think the "explicit support" means that the model is automatically re-filtered when child nodes are added. How do you re-filter when your search changes? By calling gtk_tree_model_filter_refilter? The problem is, that it visits the nodes in a depth-first fashion.Abscond
Yes, I call filter.refilter(), where filter is a Gtk.TreeModelFilter.Gaygaya
There is another solution: manually calculate row visibility (only once), store visibility flag in your model and tell treeviewfilter where to look for this flagPrecocious
@AlexanderDmitriev Thanks for the suggestion, I used your approach and added a completed example as an answer.Gaygaya
G
10

Finally I came up with a solution and since I haven't found any treeview filtering examples on the internet that uses a TreeStore and not a ListStore, I'm posting my solution here as an example:

Demo screenshot

#! /usr/bin/python
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Pango', '1.0')
from gi.repository import Gtk
from gi.repository import Pango
from gi.repository import GLib
import signal

HIERARCHICAL_DATA = {
    "Queen": {
        "A Kind of Magic": [ "Who Wants to Live Forever", "A Kind of Magic" ],
        "The Miracle": [ "Breakthru", "Scandal" ]
    },
    "Five Finger Death Punch": {
        "The Way of the Fist": [ "The Way of the Fist", "The Bleeding" ],
    },
    "Hank Marvin": {
        "Heartbeat": [ "Oxygene (Part IV)", "Take Five" ]
    }
}

ICONS = [ "stock_people", "media-optical", "sound" ]

class TreeViewFilteringDemo(Gtk.Window):
    EXPAND_BY_DEFAULT = True
    SPACING = 10

    # Controls whether the row should be visible
    COL_VISIBLE = 0
    # Text to be displayed
    COL_TEXT = 1
    # Desired weight of the text (bold for matching rows)
    COL_WEIGHT = 2
    # Icon to be displayed
    COL_ICON = 3

    def __init__(self):
        # Set up window
        Gtk.Window.__init__(self, title="TreeView filtering demo")
        self.set_size_request(500, 500)
        self.set_position(Gtk.WindowPosition.CENTER)
        self.set_resizable(True)
        self.set_border_width(self.SPACING)

        # Set up and populate a tree store
        self.tree_store = Gtk.TreeStore(bool, str, Pango.Weight, str)
        self.add_nodes(HIERARCHICAL_DATA, None, 0)

        # Create some boxes for laying out the different controls
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=self.SPACING)
        vbox.set_homogeneous(False)
        hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL, spacing=self.SPACING)
        hbox.set_homogeneous(False)
        vbox.pack_start(hbox, False, True, 0)
        self.add(vbox)

        # A text entry for filtering
        self.search_entry = Gtk.Entry()
        self.search_entry.set_placeholder_text("Enter text here to filter results")
        self.search_entry.connect("changed", self.refresh_results)
        hbox.pack_start(self.search_entry, True, True, 0)

        # Add a checkbox for controlling subtree display
        self.subtree_checkbox = Gtk.CheckButton("Show subtrees of matches")
        self.subtree_checkbox.connect("toggled", self.refresh_results)
        hbox.pack_start(self.subtree_checkbox, False, False, 0)

        # Use an internal column for filtering
        self.filter = self.tree_store.filter_new()
        self.filter.set_visible_column(self.COL_VISIBLE)
        self.treeview = Gtk.TreeView(model=self.filter)

        # CellRenderers for icons and texts
        icon_renderer = Gtk.CellRendererPixbuf()
        text_renderer = Gtk.CellRendererText()

        # Put the icon and the text into a single column (otherwise only the
        # first column would be indented according to its depth in the tree)
        col_combined = Gtk.TreeViewColumn("Icon and Text")
        col_combined.pack_start(icon_renderer, False)
        col_combined.pack_start(text_renderer, False)
        col_combined.add_attribute(text_renderer, "text", self.COL_TEXT)
        col_combined.add_attribute(text_renderer, "weight", self.COL_WEIGHT)
        col_combined.add_attribute(icon_renderer, "icon_name", self.COL_ICON)
        self.treeview.append_column(col_combined)

        # Scrolled Window in case results don't fit in the available space
        self.sw = Gtk.ScrolledWindow()
        self.sw.add(self.treeview)

        vbox.pack_start(self.sw, True, True, 0)

        # Initialize filtering
        self.refresh_results()

    def add_nodes(self, data, parent, level):
        "Create the tree nodes from a hierarchical data structure"
        if isinstance(data, dict):
            for key, value in data.items():
                child = self.tree_store.append(parent, [True, key, Pango.Weight.NORMAL, ICONS[level]])
                self.add_nodes(value, child, level + 1)
        else:
            for text in data:
                self.tree_store.append(parent, [True, text, Pango.Weight.NORMAL, ICONS[level]])

    def refresh_results(self, _widget = None):
        "Apply filtering to results"
        search_query = self.search_entry.get_text().lower()
        show_subtrees_of_matches = self.subtree_checkbox.get_active()
        if search_query == "":
            self.tree_store.foreach(self.reset_row, True)
            if self.EXPAND_BY_DEFAULT:
                self.treeview.expand_all()
            else:
                self.treeview.collapse_all()
        else:
            self.tree_store.foreach(self.reset_row, False)
            self.tree_store.foreach(self.show_matches, search_query, show_subtrees_of_matches)
            self.treeview.expand_all()
        self.filter.refilter()

    def reset_row(self, model, path, iter, make_visible):
        "Reset some row attributes independent of row hierarchy"
        self.tree_store.set_value(iter, self.COL_WEIGHT, Pango.Weight.NORMAL)
        self.tree_store.set_value(iter, self.COL_VISIBLE, make_visible)

    def make_path_visible(self, model, iter):
        "Make a row and its ancestors visible"
        while iter:
            self.tree_store.set_value(iter, self.COL_VISIBLE, True)
            iter = model.iter_parent(iter)

    def make_subtree_visible(self, model, iter):
        "Make descendants of a row visible"
        for i in range(model.iter_n_children(iter)):
            subtree = model.iter_nth_child(iter, i)
            if model.get_value(subtree, self.COL_VISIBLE):
                # Subtree already visible
                continue
            self.tree_store.set_value(subtree, self.COL_VISIBLE, True)
            self.make_subtree_visible(model, subtree)

    def show_matches(self, model, path, iter, search_query, show_subtrees_of_matches):
        text = model.get_value(iter, self.COL_TEXT).lower()
        if search_query in text:
            # Highlight direct match with bold
            self.tree_store.set_value(iter, self.COL_WEIGHT, Pango.Weight.BOLD)
            # Propagate visibility change up
            self.make_path_visible(model, iter)
            if show_subtrees_of_matches:
                # Propagate visibility change down
                self.make_subtree_visible(model, iter)
            return

win = TreeViewFilteringDemo()
win.connect("delete-event", Gtk.main_quit)
win.show_all()
# Make sure that the application can be stopped from the terminal using Ctrl-C
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit)
Gtk.main()
Gaygaya answered 8/5, 2019 at 19:5 Comment(1)
Needs some explanation on the gist of making parent node visible when there is any visible child, not just pasting the full Python code. I am not familiar with Python and I am using GTK# (C#), so I am not getting what exactly you are doing.Atony

© 2022 - 2024 — McMap. All rights reserved.