List Models
List models are a simple interface for ordered lists of GObject
instances. It has become the preferred method for populating list, grid and tree widgets. A default implementation is provided, optimized for linear iteration.
GTK4 also includes additional Gio.ListModel
implementations that can filter, sort, flatten nested lists, and more. This guide focuses on the implementation and usage of Gio.ListModel
, primarily as introductory material for list view widgets and others in GTK4.
See Also
Basic Implementation
NOTE
Gio.ListModel
only works with types that inherit from GObject.Object
, and will not accept boxed types such as GObject.TYPE_JSOBJECT
.
Gio.ListModel
defines a read-only interface, intended to be used from a single thread. This makes implementation very simple, requiring only three methods, and emitting one signal at the appropriate time.
Gio.ListModel.get_item(position)
This method must return the
GObject.Object
atposition
, ornull
if an invalid position is passed. This allows easily iterating the list, without checking the length.Gio.ListModel.get_item_type()
This method must return a GType shared by all objects in the list. It may simply be
GObject.Object
, or any other common ancestor orGObject.Interface
of the objects.Gio.ListModel.get_n_items()
This method must return the number of items in the list. More importantly, when
Gio.ListModel::items-changed
is emitted, it must return the current value.
The ordered nature of the Gio.ListModel
interface also makes it easy to add support for the JavaScript Iterator protocol. The best way to demonstrate the list model API is to wrap one around an Array
and expose the elements:
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
var ArrayStore = GObject.registerClass({
Implements: [Gio.ListModel],
}, class ArrayStore extends GObject.Object {
/* A native Array as internal storage for the list model */
#items = [];
/*
* Wrapping the internal iterable is an easy way to support `for..of` loops
*/
*[Symbol.iterator]() {
for (const item of this.#items)
yield item;
}
/**
* Gets the item at @position.
*
* If @position is greater than the number of items in the list, %null is
* returned. %null is never returned for a position that is smaller than
* the length of the list.
*
* @param {number} position - the position of the item to fetch
* @returns {GObject.Object|null} - the object at @position
*/
vfunc_get_item(position) {
return this.#items[position] || null;
}
/**
* Gets the item type of the list.
*
* All items in the model must of this type, or derived from it. If the
* type itself is an interface, the items must implement that interface.
*
* @returns {GObject.GType} the type of object in the list
*/
vfunc_get_item_type() {
return GObject.Object;
}
/**
* Gets the number of items in the list.
*
* Depending on the model implementation, calling this function may be
* less efficient than iterating the list with increasing values for
* position until `null` is returned.
*
* @returns {number} - a positive integer
*/
vfunc_get_n_items() {
return this.#items.length;
}
/*
* NOTE: The methods below are not part of the GListModel interface.
*/
/**
* Insert an item in the list. If @position is greater than the number of
* items in the list or less than `0` it will be appended to the end of the
* list.
*
* @param {GObject.Object} item - the item to add
* @param {number} [position] - the position to add the item
*/
insertItem(item, position = -1) {
// Type check the item
if (!(item instanceof GObject.Object))
throw TypeError(`Not a GObject: ${item.constructor.name}`);
if (!GObject.type_is_a(item.constructor.$gtype, this.get_item_type()))
throw TypeError(`Invalid type: ${item.constructor.$gtype.name}`);
// Normalize the position
if (position < 0 || position > this.#items.length)
position = this.#items.length;
// Insert the item, then emit Gio.ListModel::items-changed
this.#items.splice(position, 0, item);
this.items_changed(position, 0, 1);
}
/**
* Remove the item at @position. If @position is outside the length of the
* list, this function does nothing.
*
* @param {number} position - the position of the item to remove
*/
removeItem(position) {
// NOTE: The Gio.ListModel interface will ensure @position is an
// unsigned integer, but other methods must check explicitly.
if (position < 0 || position >= this.#items.length)
return;
// Remove the item and emit Gio.ListModel::items-changed
this.#items.splice(position, 1);
this.items_changed(position, 1, 0);
}
});
Basic Usage
GListStore
Gio.ListStore
is an implementation of Gio.ListModel
suitable for common use cases, with a fast-path for iterating the items sequentially. It's usually the best choice for generic usage, and implements the JavaScript Iterator protocol already.
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
const listStore = Gio.ListStore.new(GObject.TYPE_OBJECT);
listStore.connect('items-changed', (_list, position, removed, added) => {
console.log(`${removed} items were removed, and ${added} added at ${position}`);
});
const listItems = [
new GObject.Object(),
new GObject.Object(),
new GObject.Object(),
];
/*
* Adding and removing items
*/
listStore.append(listItems[0]);
listStore.insert(1, listItems[1]);
listStore.splice(2, 0, [listItems[2]]);
listStore.remove(0);
/**
* Example sort function.
*
* NOTE: This function must be deterministic to ensure a stable sort.
*
* @param {GObject.Object} object1 - a GObject
* @param {GObject.Object} object2 - a GObject
* @returns {number} `-1` if @object1 should be before @object2, `0` if
* equivalent, or `1` if @object1 should be after @object2.
*/
function sortFunc(object1, object2) {
return object1 === object2 ? 0 : -1;
}
listStore.sort(sortFunc);
listStore.insert_sorted(new GObject.Object(), sortFunc);
/**
* Example find function.
*
* @param {GObject.Object} object1 - a GObject
* @param {GObject.Object} object2 - a GObject
* @returns {boolean} %true if equivalent, %false otherwise
*/
function findFunc(object1, object2) {
return object1 === object2;
}
let [found, position] = listStore.find(listItems[0]);
if (found)
console.log('This item will not be found, because it was already removed');
[found, position] = listStore.find_with_equal_func(listItems[1], findFunc);
if (found) {
console.log(`The item found at position ${position} will be removed`);
listStore.remove(position);
}
Consuming List Models
Usually objects implementing Gio.ListModel
are bound to widget with a convenience function, or wrapped in another model like Gtk.SelectionModel
and used to populate a widget like Gtk.ListView
.
However, it's helpful to understand how these widgets will typically use list models, when first using widgets that consume list models, to understand how these widgets will use the model internally.
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
/* This array will take the place of a list view, or other widget.
*
* Internally, many widgets like GtkListBox will operate in a very similar way,
* connecting the `items-changed` signal to create and destroy widgets at the
* correct position.
*/
const listWidget = [];
const listStore = Gio.ListStore.new(Gio.File);
listStore.connect('items-changed', (list, position, removed, added) => {
console.log(`position: ${position}, removed: ${removed}, added: ${added}`);
/* Items are added and removed from the same position, so the removals
* must be handled first.
*
* NOTE: remember that the items have already changed in the model when this
* signal is emitted, so you can not query removed objects.
*/
while (removed--)
listWidget.splice(position, 1);
/* Once the removals have been processed, the additions must be inserted
* at the same position.
*/
for (let i = 0; i < added; i++)
listWidget.splice(position + i, 0, list.get_item(position + i));
});
/* Splicing the items will result in a single emission of `items-changed`, with
* a callback signature of `position = 0, removed = 0, added = 3`.
*
* Sorting the items will result in a single emission of `items-changed`, with
* a callback signature of `position = 0, removed = 3, added = 3`.
*/
listStore.splice(0, 0, [
Gio.File.new_for_path('/'),
Gio.File.new_for_path('/home'),
Gio.File.new_for_path('/home/user'),
]);
listStore.sort((object1, object2) => {
return object1.get_path().localeCompare(object2.get_path());
});
/* Inserting one at a time results in a three emissions of `items-changed`, with
* a callback signature of `position = ?, removed = 0, added = 1`.
*
* WARNING: when using a sorted list model all items must be sorted, with the
* same sorting function, or the list behavior becomes undefined.
*/
const moreItems = [
Gio.File.new_for_path('/home/user/Downloads'),
Gio.File.new_for_path('/home/user/Downloads/TV'),
Gio.File.new_for_path('/home/user/Downloads/TV/Teddy Ruxpin'),
];
for (const item of moreItems) {
listStore.insert_sorted(item, (object1, object2) => {
return object1.get_path().localeCompare(object2.get_path());
});
}
/* We should now be in state where the number and order of items is the same,
* both in the list model and the list consumer.
*/
if (listStore.n_items !== listWidget.length)
throw Error('Should never be thrown');
for (let i = 0; i < listStore.n_items; i++) {
if (listWidget[i] !== listStore.get_item(i))
throw Error('Should never be thrown');
}